From 6bfa05cb452a6f8ac4351c89dbbeb2a17b8507bc Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 18 May 2026 14:41:15 +0800 Subject: [PATCH 01/12] add embeddable scanner SDK --- .gitignore | 4 +- common/callback.go | 17 +++ common/logger.go | 10 ++ pkg/fscan/README.md | 39 +++++ pkg/fscan/doc.go | 7 + pkg/fscan/scanner.go | 308 ++++++++++++++++++++++++++++++++++++++ pkg/fscan/scanner_test.go | 66 ++++++++ pkg/fscan/types.go | 66 ++++++++ 8 files changed, 515 insertions(+), 2 deletions(-) create mode 100644 pkg/fscan/README.md create mode 100644 pkg/fscan/doc.go create mode 100644 pkg/fscan/scanner.go create mode 100644 pkg/fscan/scanner_test.go create mode 100644 pkg/fscan/types.go diff --git a/.gitignore b/.gitignore index ed5ad95..66c0f15 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,9 @@ result.txt result.json -main +/main .idea fscan.exe -fscan +/fscan fscanapi.csv # IDE files / IDE 文件 diff --git a/common/callback.go b/common/callback.go index ac84741..1000a0e 100644 --- a/common/callback.go +++ b/common/callback.go @@ -17,6 +17,23 @@ func SetResultCallback(cb ResultCallback) { resultCallback = cb } +// ReplaceResultCallback temporarily replaces the result callback and returns a +// restore function. This is useful for embedded callers that need to collect +// structured results without permanently stealing the callback from another +// subsystem. +func ReplaceResultCallback(cb ResultCallback) func() { + callbackMu.Lock() + previous := resultCallback + resultCallback = cb + callbackMu.Unlock() + + return func() { + callbackMu.Lock() + resultCallback = previous + callbackMu.Unlock() + } +} + // NotifyResult 通知结果给回调函数 func NotifyResult(result interface{}) { callbackMu.RLock() diff --git a/common/logger.go b/common/logger.go index 323a013..a3f018c 100644 --- a/common/logger.go +++ b/common/logger.go @@ -88,3 +88,13 @@ func CloseLogger() { globalLogger.Close() } } + +// ResetLogger clears the process-wide logger so embedded callers can rebuild it +// after replacing runtime configuration. +func ResetLogger() { + if globalLogger != nil { + globalLogger.Close() + } + globalLogger = nil + loggerOnce = sync.Once{} +} diff --git a/pkg/fscan/README.md b/pkg/fscan/README.md new file mode 100644 index 0000000..b1ce772 --- /dev/null +++ b/pkg/fscan/README.md @@ -0,0 +1,39 @@ +# fscan SDK + +`pkg/fscan` exposes fscan as an embeddable Go scanner while keeping the CLI unchanged. + +```go +package main + +import ( + "context" + "fmt" + "time" + + fscan "github.com/shadow1ng/fscan/pkg/fscan" +) + +func main() { + scanner := fscan.NewScanner(fscan.Config{ + Timeout: 3 * time.Second, + Threads: 128, + DisablePing: true, + DisableBrute: true, + Plugins: []string{"ssh", "mysql", "redis"}, + }) + + results, err := scanner.Scan(context.Background(), fscan.Target{ + Host: "192.168.1.10", + Ports: []int{22, 3306, 6379}, + }) + if err != nil { + panic(err) + } + + for _, result := range results { + fmt.Printf("%s %s %s\n", result.Type, result.Target, result.Status) + } +} +``` + +The SDK currently reuses fscan's existing scan core and plugin registry. Calls are serialized internally because the current core still keeps process-wide runtime state. diff --git a/pkg/fscan/doc.go b/pkg/fscan/doc.go new file mode 100644 index 0000000..ea441e6 --- /dev/null +++ b/pkg/fscan/doc.go @@ -0,0 +1,7 @@ +// Package fscan exposes fscan as an embeddable scanner library. +// +// The package is intentionally thin: it reuses the existing scan core and +// plugin registry, while hiding CLI flags, stdout output, and result files from +// callers. The first SDK surface is serialized internally because the current +// scan core still uses process-wide runtime state. +package fscan diff --git a/pkg/fscan/scanner.go b/pkg/fscan/scanner.go new file mode 100644 index 0000000..4692291 --- /dev/null +++ b/pkg/fscan/scanner.go @@ -0,0 +1,308 @@ +package fscan + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/shadow1ng/fscan/common" + commonconfig "github.com/shadow1ng/fscan/common/config" + "github.com/shadow1ng/fscan/common/i18n" + "github.com/shadow1ng/fscan/core" + "github.com/shadow1ng/fscan/plugins" + + _ "github.com/shadow1ng/fscan/plugins/local" + _ "github.com/shadow1ng/fscan/plugins/services" + _ "github.com/shadow1ng/fscan/plugins/web" +) + +var scanMu sync.Mutex + +// Scanner runs fscan from another Go process. +type Scanner struct { + config Config +} + +// NewScanner creates an embedded scanner. +func NewScanner(config Config) *Scanner { + return &Scanner{config: config} +} + +// Scan runs the scanner for the provided targets and returns structured +// findings. If no targets are provided, Config.Targets is used. +func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) { + if ctx == nil { + ctx = context.Background() + } + if len(targets) == 0 { + targets = s.config.Targets + } + if err := validateConfig(s.config, targets); err != nil { + return nil, err + } + + scanMu.Lock() + defer scanMu.Unlock() + + var ( + mu sync.Mutex + results []Result + ) + restoreCallback := common.ReplaceResultCallback(func(raw interface{}) { + if result, ok := decodeCallbackResult(raw); ok { + mu.Lock() + results = append(results, result) + mu.Unlock() + } + }) + defer restoreCallback() + + for _, target := range targets { + if err := ctx.Err(); err != nil { + return snapshotResults(&mu, results), err + } + if err := s.scanOne(ctx, target); err != nil { + return snapshotResults(&mu, results), err + } + } + + return snapshotResults(&mu, results), ctx.Err() +} + +func (s *Scanner) scanOne(ctx context.Context, target Target) error { + fv := buildFlagVars(s.config, target) + globalFV := common.GetFlagVars() + *globalFV = *fv + info := common.HostInfo{Host: strings.TrimSpace(target.Host), URL: strings.TrimSpace(target.URL)} + + i18n.SetLanguage(globalFV.Language) + + cfg, state, err := common.BuildConfig(globalFV, &info) + if err != nil { + return err + } + if len(s.config.UserPassPairs) > 0 { + cfg.Credentials.UserPassPairs = make([]commonconfig.CredentialPair, 0, len(s.config.UserPassPairs)) + for _, pair := range s.config.UserPassPairs { + cfg.Credentials.UserPassPairs = append(cfg.Credentials.UserPassPairs, commonconfig.CredentialPair{ + Username: pair.Username, + Password: pair.Password, + }) + } + } + cfg.Output.DisableSave = true + cfg.Output.Silent = true + cfg.Output.DisableProgress = true + cfg.Output.ShowProgress = false + + common.SetGlobalConfig(cfg) + common.SetGlobalState(state) + common.ResetLogger() + common.InitLogger() + + session := common.NewScanSession(cfg, state, globalFV) + core.RunScan(ctx, info, session) + return nil +} + +func validateConfig(config Config, targets []Target) error { + if len(targets) == 0 { + return fmt.Errorf("fscan: at least one target is required") + } + for _, plugin := range config.Plugins { + name := strings.TrimSpace(plugin) + if name == "" { + continue + } + if !plugins.Exists(name) { + return fmt.Errorf("fscan: plugin %q not found", name) + } + } + for _, target := range targets { + if strings.TrimSpace(target.Host) == "" && strings.TrimSpace(target.URL) == "" { + return fmt.Errorf("fscan: target host or URL is required") + } + if strings.TrimSpace(target.Host) != "" && strings.TrimSpace(target.URL) != "" { + return fmt.Errorf("fscan: target cannot set both Host and URL") + } + for _, port := range target.Ports { + if port < 1 || port > 65535 { + return fmt.Errorf("fscan: invalid port %d", port) + } + } + } + for _, port := range config.Ports { + if port < 1 || port > 65535 { + return fmt.Errorf("fscan: invalid port %d", port) + } + } + return nil +} + +func buildFlagVars(config Config, target Target) *common.FlagVars { + timeout := secondsOrDefault(config.Timeout, common.DefaultTimeout) + webTimeout := secondsOrDefault(config.WebTimeout, 5) + + threadNum := config.Threads + if threadNum <= 0 { + threadNum = common.DefaultThreadNum + } + moduleThreads := config.ModuleThreads + if moduleThreads <= 0 { + moduleThreads = 20 + } + maxRetries := config.MaxRetries + if maxRetries <= 0 { + maxRetries = 3 + } + maxRedirects := config.MaxRedirects + if maxRedirects <= 0 { + maxRedirects = 10 + } + pocConcurrency := config.POCConcurrency + if pocConcurrency <= 0 { + pocConcurrency = 20 + } + icmpRate := config.ICMPRate + if icmpRate <= 0 { + icmpRate = 0.1 + } + language := config.Language + if language == "" { + language = common.DefaultLanguage + } + + ports := config.Ports + if len(target.Ports) > 0 { + ports = target.Ports + } + + return &common.FlagVars{ + Host: strings.TrimSpace(target.Host), + Ports: formatPorts(ports), + ScanMode: formatPlugins(config.Plugins), + ThreadNum: threadNum, + ModuleThreadNum: moduleThreads, + TimeoutSec: timeout, + GlobalTimeout: 180, + DisablePing: config.DisablePing, + DisableTcpProbe: config.DisableTCPProbe, + AliveOnly: false, + DisableBrute: config.DisableBrute, + MaxRetries: maxRetries, + Username: strings.Join(config.Usernames, ","), + Password: strings.Join(config.Passwords, ","), + Domain: config.Domain, + SSHKeyPath: config.SSHKeyPath, + TargetURL: strings.TrimSpace(target.URL), + WebTimeout: webTimeout, + MaxRedirects: maxRedirects, + HTTPProxy: config.HTTPProxy, + Socks5Proxy: config.Socks5Proxy, + Iface: config.Interface, + PocPath: config.POCPath, + PocName: config.POCName, + PocFull: config.POCFull, + PocNum: pocConcurrency, + DisablePocScan: config.DisablePOCScan, + PacketRateLimit: config.PacketRateLimit, + MaxPacketCount: config.MaxPacketCount, + ICMPRate: icmpRate, + Outputfile: "result.txt", + OutputFormat: "txt", + DisableSave: true, + Silent: true, + NoColor: true, + LogLevel: common.LogLevelError, + DisableProgress: true, + Language: language, + ForwardShellPort: 4444, + KeyloggerOutputFile: "keylog.txt", + } +} + +func formatPlugins(plugins []string) string { + if len(plugins) == 0 { + return "all" + } + parts := make([]string, 0, len(plugins)) + for _, plugin := range plugins { + plugin = strings.TrimSpace(plugin) + if plugin != "" { + parts = append(parts, plugin) + } + } + if len(parts) == 0 { + return "all" + } + return strings.Join(parts, ",") +} + +func formatPorts(ports []int) string { + if len(ports) == 0 { + return commonconfig.MainPorts + } + ports = append([]int(nil), ports...) + sort.Ints(ports) + parts := make([]string, 0, len(ports)) + for _, port := range ports { + parts = append(parts, strconv.Itoa(port)) + } + return strings.Join(parts, ",") +} + +func secondsOrDefault(value time.Duration, fallback int) int64 { + if value <= 0 { + return int64(fallback) + } + seconds := int64(value.Round(time.Second) / time.Second) + if seconds < 1 { + return 1 + } + return seconds +} + +func decodeCallbackResult(raw interface{}) (Result, bool) { + item, ok := raw.(map[string]interface{}) + if !ok { + return Result{}, false + } + + result := Result{ + Type: stringValue(item["type"]), + Target: stringValue(item["target"]), + Status: stringValue(item["status"]), + Details: mapValue(item["details"]), + } + if t, ok := item["time"].(time.Time); ok { + result.Time = t + } + return result, result.Target != "" || result.Status != "" +} + +func stringValue(value interface{}) string { + if s, ok := value.(string); ok { + return s + } + return "" +} + +func mapValue(value interface{}) map[string]interface{} { + if value == nil { + return nil + } + if m, ok := value.(map[string]interface{}); ok { + return m + } + return nil +} + +func snapshotResults(mu *sync.Mutex, results []Result) []Result { + mu.Lock() + defer mu.Unlock() + return append([]Result(nil), results...) +} diff --git a/pkg/fscan/scanner_test.go b/pkg/fscan/scanner_test.go new file mode 100644 index 0000000..c772181 --- /dev/null +++ b/pkg/fscan/scanner_test.go @@ -0,0 +1,66 @@ +package fscan + +import ( + "context" + "testing" + "time" + + commonconfig "github.com/shadow1ng/fscan/common/config" +) + +func TestBuildFlagVarsDefaults(t *testing.T) { + fv := buildFlagVars(Config{}, Target{Host: "127.0.0.1"}) + + if fv.Host != "127.0.0.1" { + t.Fatalf("Host = %q", fv.Host) + } + if fv.Ports != commonconfig.MainPorts { + t.Fatalf("Ports = %q, want MainPorts", fv.Ports) + } + if fv.ScanMode != "all" { + t.Fatalf("ScanMode = %q, want all", fv.ScanMode) + } + if !fv.DisableSave || !fv.Silent || !fv.DisableProgress { + t.Fatalf("embedded defaults should disable output side effects") + } +} + +func TestBuildFlagVarsTargetPortsOverride(t *testing.T) { + fv := buildFlagVars(Config{Ports: []int{22, 80}}, Target{Host: "127.0.0.1", Ports: []int{3306, 22}}) + + if fv.Ports != "22,3306" { + t.Fatalf("Ports = %q, want sorted target override", fv.Ports) + } +} + +func TestValidateConfig(t *testing.T) { + if err := validateConfig(Config{}, nil); err == nil { + t.Fatal("expected missing target error") + } + if err := validateConfig(Config{}, []Target{{Host: "127.0.0.1", URL: "http://127.0.0.1"}}); err == nil { + t.Fatal("expected host/url conflict") + } + if err := validateConfig(Config{Plugins: []string{"definitely-missing"}}, []Target{{Host: "127.0.0.1"}}); err == nil { + t.Fatal("expected missing plugin error") + } + if err := validateConfig(Config{}, []Target{{Host: "127.0.0.1", Ports: []int{70000}}}); err == nil { + t.Fatal("expected invalid port error") + } +} + +func TestScanHonorsCanceledContext(t *testing.T) { + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Plugins: []string{"redis"}, + }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := scanner.Scan(ctx, Target{Host: "127.0.0.1", Ports: []int{6379}}) + if err != context.Canceled { + t.Fatalf("Scan error = %v, want context.Canceled", err) + } +} diff --git a/pkg/fscan/types.go b/pkg/fscan/types.go new file mode 100644 index 0000000..749c5b0 --- /dev/null +++ b/pkg/fscan/types.go @@ -0,0 +1,66 @@ +package fscan + +import ( + "time" +) + +// Target describes one scan target. Use Host for host/IP/CIDR/range service +// scans, or URL for web scans. Ports applies only to Host scans. +type Target struct { + Host string + URL string + Ports []int +} + +// CredentialPair pins one username/password pair. +type CredentialPair struct { + Username string + Password string +} + +// Config controls an embedded scan. Zero values use the same conservative +// defaults as the CLI, except output is silent and file saving is disabled. +type Config struct { + Targets []Target + + Plugins []string + Ports []int + + Timeout time.Duration + Threads int + ModuleThreads int + MaxRetries int + + DisablePing bool + DisableTCPProbe bool + DisableBrute bool + + Usernames []string + Passwords []string + UserPassPairs []CredentialPair + Domain string + SSHKeyPath string + HTTPProxy string + Socks5Proxy string + Interface string + WebTimeout time.Duration + MaxRedirects int + DisablePOCScan bool + POCPath string + POCName string + POCFull bool + POCConcurrency int + PacketRateLimit int64 + MaxPacketCount int64 + ICMPRate float64 + Language string +} + +// Result is the structured scan result returned to embedded callers. +type Result struct { + Time time.Time `json:"time"` + Type string `json:"type"` + Target string `json:"target"` + Status string `json:"status"` + Details map[string]interface{} `json:"details,omitempty"` +} From 6605c93dd929e889fcc8aecdeb702a9631b69dec Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 18 May 2026 14:53:00 +0800 Subject: [PATCH 02/12] improve embedded scanner runtime --- common/callback.go | 17 ---- common/session.go | 22 ++++- core/base_scan_strategy.go | 5 ++ core/icmp.go | 8 +- core/port_scan.go | 10 +-- core/scanner.go | 6 +- pkg/fscan/README.md | 9 +- pkg/fscan/scanner.go | 171 +++++++++++++++++++++++++++---------- pkg/fscan/scanner_test.go | 95 ++++++++++++++++++++- pkg/fscan/types.go | 5 ++ 10 files changed, 267 insertions(+), 81 deletions(-) diff --git a/common/callback.go b/common/callback.go index 1000a0e..ac84741 100644 --- a/common/callback.go +++ b/common/callback.go @@ -17,23 +17,6 @@ func SetResultCallback(cb ResultCallback) { resultCallback = cb } -// ReplaceResultCallback temporarily replaces the result callback and returns a -// restore function. This is useful for embedded callers that need to collect -// structured results without permanently stealing the callback from another -// subsystem. -func ReplaceResultCallback(cb ResultCallback) func() { - callbackMu.Lock() - previous := resultCallback - resultCallback = cb - callbackMu.Unlock() - - return func() { - callbackMu.Lock() - resultCallback = previous - callbackMu.Unlock() - } -} - // NotifyResult 通知结果给回调函数 func NotifyResult(result interface{}) { callbackMu.RLock() diff --git a/common/session.go b/common/session.go index 464426d..97193cb 100644 --- a/common/session.go +++ b/common/session.go @@ -8,16 +8,21 @@ import ( "sync" "time" - "github.com/shadow1ng/fscan/common/proxy" "github.com/shadow1ng/fscan/common/i18n" + "github.com/shadow1ng/fscan/common/output" + "github.com/shadow1ng/fscan/common/proxy" ) +// ResultSink receives structured scan results for one scan session. +type ResultSink func(result *output.ScanResult) error + // ScanSession 封装单次扫描的全部上下文 // 一次扫描一个 session,并发扫描各自独立 type ScanSession struct { - Config *Config // 不可变,创建后只读 - State *State // 可变,原子操作,每会话独立 - Params *FlagVars // 原始参数,只读 + Config *Config // 不可变,创建后只读 + State *State // 可变,原子操作,每会话独立 + Params *FlagVars // 原始参数,只读 + ResultSink ResultSink // 可选,覆盖全局输出 // 每会话 dialer(懒初始化,取决于代理配置) dialerOnce sync.Once @@ -34,6 +39,15 @@ func NewScanSession(config *Config, state *State, params *FlagVars) *ScanSession } } +// SaveResult saves a scan result through the session sink if present, otherwise +// falls back to the process-wide output pipeline used by the CLI. +func (s *ScanSession) SaveResult(result *output.ScanResult) error { + if s != nil && s.ResultSink != nil { + return s.ResultSink(result) + } + return SaveResult(result) +} + // DialTCP 创建 TCP 连接,内含限速检查、代理、计数 func (s *ScanSession) DialTCP(ctx context.Context, network, address string, timeout time.Duration) (net.Conn, error) { // 检查发包限制 diff --git a/core/base_scan_strategy.go b/core/base_scan_strategy.go index 17ba2e8..0b5c69f 100644 --- a/core/base_scan_strategy.go +++ b/core/base_scan_strategy.go @@ -81,6 +81,11 @@ func (b *BaseScanStrategy) IsPluginApplicableByName(pluginName string, targetHos return false } + // 显式指定插件时,尊重调用方选择,不再强制使用插件默认端口过滤。 + if isCustomMode { + return b.isPluginPassesFilterType(pluginName, isCustomMode, config) + } + // 检查端口匹配和过滤器类型 return b.isPluginApplicableToPortWithHost(pluginName, targetHost, targetPort) && b.isPluginPassesFilterType(pluginName, isCustomMode, config) } diff --git a/core/icmp.go b/core/icmp.go index 80cb4b5..a800fba 100644 --- a/core/icmp.go +++ b/core/icmp.go @@ -55,7 +55,7 @@ func CheckLive(ctx context.Context, hostslist []string, Ping bool, session *comm chanHosts := make(chan string, len(hostslist)) // 处理存活主机 - go handleAliveHosts(chanHosts, hostslist, Ping, &aliveHosts, &aliveHostsMu, existHosts, config, &livewg) + go handleAliveHosts(chanHosts, hostslist, Ping, &aliveHosts, &aliveHostsMu, existHosts, config, session, &livewg) // 根据Ping参数选择检测方式 if Ping { @@ -130,7 +130,7 @@ func IsContain(items []string, item string) bool { return false } -func handleAliveHosts(chanHosts chan string, hostslist []string, isPing bool, aliveHosts *[]string, aliveHostsMu *sync.Mutex, existHosts map[string]struct{}, config *common.Config, livewg *sync.WaitGroup) { +func handleAliveHosts(chanHosts chan string, hostslist []string, isPing bool, aliveHosts *[]string, aliveHostsMu *sync.Mutex, existHosts map[string]struct{}, config *common.Config, session *common.ScanSession, livewg *sync.WaitGroup) { for ip := range chanHosts { if _, ok := existHosts[ip]; !ok && IsContain(hostslist, ip) { existHosts[ip] = struct{}{} @@ -155,7 +155,7 @@ func handleAliveHosts(chanHosts chan string, hostslist []string, isPing bool, al "protocol": protocol, }, } - _ = common.SaveResult(result) + _ = session.SaveResult(result) // 保留原有的控制台输出 if !config.Output.Silent { @@ -771,7 +771,7 @@ func runTcpProbeForHosts(ctx context.Context, hosts []string, session *common.Sc "protocol": "TCP", }, } - _ = common.SaveResult(result) + _ = session.SaveResult(result) if !config.Output.Silent { common.LogInfo(i18n.Tr("host_alive", h, "TCP")) diff --git a/core/port_scan.go b/core/port_scan.go index 9bca839..30f815f 100644 --- a/core/port_scan.go +++ b/core/port_scan.go @@ -487,7 +487,7 @@ func scanSinglePort(ctx context.Context, host string, port int, addr string, ada // 步骤2:记录开放端口 atomic.AddInt64(count, 1) collector.Add(addr) - saveOpenPort(host, port) + saveOpenPort(session, host, port) // 步骤3:服务识别(Scanner负责关闭连接,包括探测中可能创建的新连接) scanner := NewSmartPortInfoScanner(ctx, host, port, conn, timeout, config, session) @@ -640,8 +640,8 @@ func isConnectionClosed(err error) bool { } // saveOpenPort 保存开放端口结果 -func saveOpenPort(host string, port int) { - _ = common.SaveResult(&output.ScanResult{ +func saveOpenPort(session *common.ScanSession, host string, port int) { + _ = session.SaveResult(&output.ScanResult{ Time: time.Now(), Type: output.TypePort, Target: host, @@ -669,7 +669,7 @@ func processServiceResult(host string, port int, addr string, serviceInfo *Servi MarkAsWebService(host, port, serviceInfo) } - _ = common.SaveResult(&output.ScanResult{ + _ = session.SaveResult(&output.ScanResult{ Time: time.Now(), Type: output.TypeService, Target: fmt.Sprintf("%s:%d", host, port), @@ -737,7 +737,7 @@ func tryHTTPFallbackDetection(host string, port int, addr string, config *common "is_web": true, "detected_by": "http_probe", } - _ = common.SaveResult(&output.ScanResult{ + _ = session.SaveResult(&output.ScanResult{ Time: time.Now(), Type: output.TypeService, Target: fmt.Sprintf("%s:%d", host, port), diff --git a/core/scanner.go b/core/scanner.go index 807dd24..5ef4032 100644 --- a/core/scanner.go +++ b/core/scanner.go @@ -281,7 +281,7 @@ func executeScanTask(ctx context.Context, session *common.ScanSession, pluginNam if result != nil { if result.Success { // 保存成功的扫描结果到文件 - savePluginResult(&target, pluginName, result) + savePluginResult(session, &target, pluginName, result) } else if result.Type == plugins.ResultTypeCredential { // 凭据测试完成但未发现弱密码,在error级别输出提示 common.LogError(i18n.Tr("brute_no_weak_pass", target.Host, target.Port, pluginName)) @@ -382,7 +382,7 @@ var defaultSerializer = resultSerializer{ } // savePluginResult 保存插件扫描结果 -func savePluginResult(info *common.HostInfo, pluginName string, result *plugins.Result) { +func savePluginResult(session *common.ScanSession, info *common.HostInfo, pluginName string, result *plugins.Result) { if result == nil || !result.Success || result.Skipped { return } @@ -402,7 +402,7 @@ func savePluginResult(info *common.HostInfo, pluginName string, result *plugins. // 保存结果 target := info.Target() - _ = common.SaveResult(&output.ScanResult{ + _ = session.SaveResult(&output.ScanResult{ Time: time.Now(), Type: serializer.outputType, Target: target, diff --git a/pkg/fscan/README.md b/pkg/fscan/README.md index b1ce772..8cec8b4 100644 --- a/pkg/fscan/README.md +++ b/pkg/fscan/README.md @@ -20,6 +20,9 @@ func main() { DisablePing: true, DisableBrute: true, Plugins: []string{"ssh", "mysql", "redis"}, + OnResult: func(result fscan.Result) { + fmt.Printf("%s %s %s\n", result.Type, result.Target, result.Status) + }, }) results, err := scanner.Scan(context.Background(), fscan.Target{ @@ -30,10 +33,10 @@ func main() { panic(err) } - for _, result := range results { - fmt.Printf("%s %s %s\n", result.Type, result.Target, result.Status) - } + fmt.Printf("total results: %d\n", len(results)) } ``` The SDK currently reuses fscan's existing scan core and plugin registry. Calls are serialized internally because the current core still keeps process-wide runtime state. + +By default, the SDK runs a conservative service-oriented plugin set and blocks plugins with local side effects or active POC behavior. Set `AllowUnsafePlugins` only when the embedding system explicitly wants those capabilities. diff --git a/pkg/fscan/scanner.go b/pkg/fscan/scanner.go index 4692291..c04a5cc 100644 --- a/pkg/fscan/scanner.go +++ b/pkg/fscan/scanner.go @@ -12,6 +12,7 @@ import ( "github.com/shadow1ng/fscan/common" commonconfig "github.com/shadow1ng/fscan/common/config" "github.com/shadow1ng/fscan/common/i18n" + "github.com/shadow1ng/fscan/common/output" "github.com/shadow1ng/fscan/core" "github.com/shadow1ng/fscan/plugins" @@ -22,6 +23,56 @@ import ( var scanMu sync.Mutex +var defaultSafePlugins = []string{ + "activemq", + "cassandra", + "elasticsearch", + "ftp", + "kafka", + "ldap", + "memcached", + "mongodb", + "mssql", + "mysql", + "neo4j", + "netbios", + "oracle", + "postgresql", + "rabbitmq", + "rdp", + "redis", + "rsync", + "smb", + "smtp", + "ssh", + "telnet", + "vnc", + "webtitle", +} + +var unsafePlugins = map[string]struct{}{ + "cleaner": {}, + "crontask": {}, + "download": {}, + "forwardshell": {}, + "keylogger": {}, + "ldpreload": {}, + "minidump": {}, + "reverseshell": {}, + "socks5proxy": {}, + "sshkey": {}, + "systemdservice": {}, + "winbits": {}, + "winifeo": {}, + "winlogon": {}, + "winregistry": {}, + "winschtask": {}, + "winservice": {}, + "winstartup": {}, + "winwmi": {}, + "webpoc": {}, +} + // Scanner runs fscan from another Go process. type Scanner struct { config Config @@ -32,6 +83,12 @@ func NewScanner(config Config) *Scanner { return &Scanner{config: config} } +// DefaultSafePlugins returns the plugin set used by the SDK when Config.Plugins +// is empty. The returned slice can be modified by callers. +func DefaultSafePlugins() []string { + return append([]string(nil), defaultSafePlugins...) +} + // Scan runs the scanner for the provided targets and returns structured // findings. If no targets are provided, Config.Targets is used. func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) { @@ -48,24 +105,30 @@ func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) scanMu.Lock() defer scanMu.Unlock() + previous := captureRuntime() + defer previous.restore() + var ( mu sync.Mutex results []Result ) - restoreCallback := common.ReplaceResultCallback(func(raw interface{}) { - if result, ok := decodeCallbackResult(raw); ok { - mu.Lock() - results = append(results, result) - mu.Unlock() - } - }) - defer restoreCallback() for _, target := range targets { if err := ctx.Err(); err != nil { return snapshotResults(&mu, results), err } - if err := s.scanOne(ctx, target); err != nil { + sink := func(raw *output.ScanResult) error { + if result, ok := convertOutputResult(raw); ok { + mu.Lock() + results = append(results, result) + mu.Unlock() + if s.config.OnResult != nil { + s.config.OnResult(result) + } + } + return nil + } + if err := s.scanOne(ctx, target, sink); err != nil { return snapshotResults(&mu, results), err } } @@ -73,7 +136,7 @@ func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) return snapshotResults(&mu, results), ctx.Err() } -func (s *Scanner) scanOne(ctx context.Context, target Target) error { +func (s *Scanner) scanOne(ctx context.Context, target Target, sink common.ResultSink) error { fv := buildFlagVars(s.config, target) globalFV := common.GetFlagVars() *globalFV = *fv @@ -105,6 +168,7 @@ func (s *Scanner) scanOne(ctx context.Context, target Target) error { common.InitLogger() session := common.NewScanSession(cfg, state, globalFV) + session.ResultSink = sink core.RunScan(ctx, info, session) return nil } @@ -121,6 +185,9 @@ func validateConfig(config Config, targets []Target) error { if !plugins.Exists(name) { return fmt.Errorf("fscan: plugin %q not found", name) } + if !config.AllowUnsafePlugins && !isSafePlugin(name) { + return fmt.Errorf("fscan: plugin %q is not enabled for embedded safe mode", name) + } } for _, target := range targets { if strings.TrimSpace(target.Host) == "" && strings.TrimSpace(target.URL) == "" { @@ -184,7 +251,7 @@ func buildFlagVars(config Config, target Target) *common.FlagVars { return &common.FlagVars{ Host: strings.TrimSpace(target.Host), Ports: formatPorts(ports), - ScanMode: formatPlugins(config.Plugins), + ScanMode: formatPlugins(config), ThreadNum: threadNum, ModuleThreadNum: moduleThreads, TimeoutSec: timeout, @@ -225,12 +292,16 @@ func buildFlagVars(config Config, target Target) *common.FlagVars { } } -func formatPlugins(plugins []string) string { - if len(plugins) == 0 { +func formatPlugins(config Config) string { + pluginNames := config.Plugins + if len(pluginNames) == 0 && !config.AllowUnsafePlugins { + pluginNames = defaultSafePlugins + } + if len(pluginNames) == 0 { return "all" } - parts := make([]string, 0, len(plugins)) - for _, plugin := range plugins { + parts := make([]string, 0, len(pluginNames)) + for _, plugin := range pluginNames { plugin = strings.TrimSpace(plugin) if plugin != "" { parts = append(parts, plugin) @@ -242,6 +313,20 @@ func formatPlugins(plugins []string) string { return strings.Join(parts, ",") } +func isSafePlugin(name string) bool { + name = strings.TrimSpace(name) + if name == "" { + return true + } + if plugins.HasType(name, plugins.PluginTypeLocal) { + return false + } + if _, bad := unsafePlugins[name]; bad { + return false + } + return true +} + func formatPorts(ports []int) string { if len(ports) == 0 { return commonconfig.MainPorts @@ -266,43 +351,43 @@ func secondsOrDefault(value time.Duration, fallback int) int64 { return seconds } -func decodeCallbackResult(raw interface{}) (Result, bool) { - item, ok := raw.(map[string]interface{}) - if !ok { +func convertOutputResult(raw *output.ScanResult) (Result, bool) { + if raw == nil { return Result{}, false } - result := Result{ - Type: stringValue(item["type"]), - Target: stringValue(item["target"]), - Status: stringValue(item["status"]), - Details: mapValue(item["details"]), - } - if t, ok := item["time"].(time.Time); ok { - result.Time = t + Time: raw.Time, + Type: string(raw.Type), + Target: raw.Target, + Status: raw.Status, + Details: raw.Details, } return result, result.Target != "" || result.Status != "" } -func stringValue(value interface{}) string { - if s, ok := value.(string); ok { - return s - } - return "" -} - -func mapValue(value interface{}) map[string]interface{} { - if value == nil { - return nil - } - if m, ok := value.(map[string]interface{}); ok { - return m - } - return nil -} - func snapshotResults(mu *sync.Mutex, results []Result) []Result { mu.Lock() defer mu.Unlock() return append([]Result(nil), results...) } + +type runtimeSnapshot struct { + flagVars common.FlagVars + config *common.Config + state *common.State +} + +func captureRuntime() runtimeSnapshot { + return runtimeSnapshot{ + flagVars: *common.GetFlagVars(), + config: common.GetGlobalConfig(), + state: common.GetGlobalState(), + } +} + +func (s runtimeSnapshot) restore() { + *common.GetFlagVars() = s.flagVars + common.SetGlobalConfig(s.config) + common.SetGlobalState(s.state) + common.ResetLogger() +} diff --git a/pkg/fscan/scanner_test.go b/pkg/fscan/scanner_test.go index c772181..01e4a72 100644 --- a/pkg/fscan/scanner_test.go +++ b/pkg/fscan/scanner_test.go @@ -2,9 +2,13 @@ package fscan import ( "context" + "net" + "strings" + "sync/atomic" "testing" "time" + "github.com/shadow1ng/fscan/common" commonconfig "github.com/shadow1ng/fscan/common/config" ) @@ -17,8 +21,8 @@ func TestBuildFlagVarsDefaults(t *testing.T) { if fv.Ports != commonconfig.MainPorts { t.Fatalf("Ports = %q, want MainPorts", fv.Ports) } - if fv.ScanMode != "all" { - t.Fatalf("ScanMode = %q, want all", fv.ScanMode) + if fv.ScanMode != formatPlugins(Config{Plugins: DefaultSafePlugins()}) { + t.Fatalf("ScanMode = %q, want safe defaults", fv.ScanMode) } if !fv.DisableSave || !fv.Silent || !fv.DisableProgress { t.Fatalf("embedded defaults should disable output side effects") @@ -43,6 +47,12 @@ func TestValidateConfig(t *testing.T) { if err := validateConfig(Config{Plugins: []string{"definitely-missing"}}, []Target{{Host: "127.0.0.1"}}); err == nil { t.Fatal("expected missing plugin error") } + if err := validateConfig(Config{Plugins: []string{"webpoc"}}, []Target{{URL: "http://127.0.0.1"}}); err == nil { + t.Fatal("expected unsafe plugin error") + } + if err := validateConfig(Config{Plugins: []string{"webpoc"}, AllowUnsafePlugins: true}, []Target{{URL: "http://127.0.0.1"}}); err != nil { + t.Fatalf("unsafe plugin with opt-in failed: %v", err) + } if err := validateConfig(Config{}, []Target{{Host: "127.0.0.1", Ports: []int{70000}}}); err == nil { t.Fatal("expected invalid port error") } @@ -64,3 +74,84 @@ func TestScanHonorsCanceledContext(t *testing.T) { t.Fatalf("Scan error = %v, want context.Canceled", err) } } + +func TestScanCollectsResultsThroughSessionSink(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(2 * time.Second)) + _, _ = conn.Write([]byte("220 test FTP\r\n")) + buf := make([]byte, 64) + _, _ = conn.Read(buf) + }(conn) + } + }() + + var callbackCalls int32 + common.SetResultCallback(func(interface{}) { + atomic.AddInt32(&callbackCalls, 1) + }) + defer common.ClearResultCallback() + + var streamed int32 + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + OnResult: func(result Result) { + atomic.AddInt32(&streamed, 1) + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + port := listener.Addr().(*net.TCPAddr).Port + results, err := scanner.Scan(ctx, Target{Host: "127.0.0.1", Ports: []int{port}}) + if err != nil { + t.Fatal(err) + } + if len(results) == 0 { + t.Fatal("expected SDK results") + } + if got := atomic.LoadInt32(&streamed); got != int32(len(results)) { + t.Fatalf("streamed length = %d, want %d", got, len(results)) + } + if !hasResult(results, "PORT", "open", "") { + t.Fatalf("missing port result: %#v", results) + } + if !hasResult(results, "SERVICE", "FTP", "ftp") { + t.Fatalf("missing ftp plugin result: %#v", results) + } + if got := atomic.LoadInt32(&callbackCalls); got != 0 { + t.Fatalf("global callback calls = %d, want 0", got) + } +} + +func hasResult(results []Result, resultType, statusText, plugin string) bool { + for _, result := range results { + if result.Type != resultType || !strings.Contains(result.Status, statusText) { + continue + } + if plugin == "" { + return true + } + if result.Details != nil && result.Details["plugin"] == plugin { + return true + } + } + return false +} diff --git a/pkg/fscan/types.go b/pkg/fscan/types.go index 749c5b0..6148b22 100644 --- a/pkg/fscan/types.go +++ b/pkg/fscan/types.go @@ -25,6 +25,11 @@ type Config struct { Plugins []string Ports []int + // AllowUnsafePlugins permits plugins with local side effects or long-lived + // behavior. It is false by default for embedded endpoint use. + AllowUnsafePlugins bool + // OnResult is called for every structured result as it is discovered. + OnResult func(Result) Timeout time.Duration Threads int From d4ed0867c9852c7a8611ec7ca0f65d39a08316b4 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 18 May 2026 15:49:57 +0800 Subject: [PATCH 03/12] polish scanner SDK API and docs --- examples/embed-basic/main.go | 40 +++++++ examples/embed-stream/main.go | 49 ++++++++ pkg/fscan/README.md | 34 +++++- pkg/fscan/doc.go | 8 ++ pkg/fscan/result.go | 218 ++++++++++++++++++++++++++++++++++ pkg/fscan/result_test.go | 122 +++++++++++++++++++ pkg/fscan/scanner.go | 203 ++++++++++++++++++++++++------- pkg/fscan/scanner_test.go | 188 +++++++++++++++++++++++++---- pkg/fscan/types.go | 45 +++++++ 9 files changed, 836 insertions(+), 71 deletions(-) create mode 100644 examples/embed-basic/main.go create mode 100644 examples/embed-stream/main.go create mode 100644 pkg/fscan/result.go create mode 100644 pkg/fscan/result_test.go diff --git a/examples/embed-basic/main.go b/examples/embed-basic/main.go new file mode 100644 index 0000000..8951d51 --- /dev/null +++ b/examples/embed-basic/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + "fmt" + "time" + + fscan "github.com/shadow1ng/fscan/pkg/fscan" +) + +func main() { + config := fscan.Config{ + Timeout: 3 * time.Second, + Threads: 64, + DisablePing: true, + DisableBrute: true, + Plugins: []string{"ssh", "mysql", "redis"}, + } + target := fscan.Target{ + Host: "127.0.0.1", + Ports: []int{22, 3306, 6379}, + } + if err := fscan.ValidateConfig(config, target); err != nil { + panic(err) + } + + scanner := fscan.NewScanner(config) + results, err := scanner.Scan(context.Background(), target) + if err != nil { + panic(err) + } + + summary := fscan.SummarizeResults(results) + fmt.Printf("scan finished: %+v\n", summary) + for _, result := range results { + if result.IsService() || result.IsVuln() { + fmt.Printf("%s %s %s\n", result.Type, result.Target, result.Status) + } + } +} diff --git a/examples/embed-stream/main.go b/examples/embed-stream/main.go new file mode 100644 index 0000000..1911da6 --- /dev/null +++ b/examples/embed-stream/main.go @@ -0,0 +1,49 @@ +package main + +import ( + "context" + "fmt" + "time" + + fscan "github.com/shadow1ng/fscan/pkg/fscan" +) + +func main() { + for _, plugin := range fscan.ListPlugins() { + if plugin.Default { + fmt.Printf("default plugin: %s ports=%v safe=%v\n", plugin.Name, plugin.Ports, plugin.Safe) + } + } + + config := fscan.Config{ + Timeout: 3 * time.Second, + Threads: 64, + DisablePing: true, + DisableBrute: true, + Plugins: []string{"ssh", "mysql", "redis"}, + } + target := fscan.Target{ + Host: "127.0.0.1", + Ports: []int{22, 3306, 6379}, + } + + var summary fscan.ResultSummary + scanner := fscan.NewScanner(config) + err := scanner.ScanEach(context.Background(), func(result fscan.Result) error { + summary.Add(result) + if service, ok := result.Service(); ok { + fmt.Printf("service=%s target=%s\n", service, result.Target) + } + if result.IsCredential() { + username, _ := result.Username() + password, _ := result.Password() + fmt.Printf("credential target=%s username=%s password=%s\n", result.Target, username, password) + } + return nil + }, target) + if err != nil { + panic(err) + } + + fmt.Printf("stream summary: %+v\n", summary) +} diff --git a/pkg/fscan/README.md b/pkg/fscan/README.md index 8cec8b4..e476884 100644 --- a/pkg/fscan/README.md +++ b/pkg/fscan/README.md @@ -2,6 +2,8 @@ `pkg/fscan` exposes fscan as an embeddable Go scanner while keeping the CLI unchanged. +See `examples/embed-basic` for slice-based collection and `examples/embed-stream` for streaming integration. + ```go package main @@ -14,18 +16,27 @@ import ( ) func main() { - scanner := fscan.NewScanner(fscan.Config{ + config := fscan.Config{ Timeout: 3 * time.Second, Threads: 128, DisablePing: true, DisableBrute: true, Plugins: []string{"ssh", "mysql", "redis"}, OnResult: func(result fscan.Result) { - fmt.Printf("%s %s %s\n", result.Type, result.Target, result.Status) + if result.Type == fscan.ResultTypeService || result.Type == fscan.ResultTypeVuln { + fmt.Printf("%s %s %s\n", result.Type, result.Target, result.Status) + } }, - }) + } + if err := fscan.ValidateConfig(config, fscan.Target{Host: "192.168.1.10"}); err != nil { + panic(err) + } - results, err := scanner.Scan(context.Background(), fscan.Target{ + scanner := fscan.NewScanner(config) + err := scanner.ScanEach(context.Background(), func(result fscan.Result) error { + // Store, forward, or filter the result in the embedding system. + return nil + }, fscan.Target{ Host: "192.168.1.10", Ports: []int{22, 3306, 6379}, }) @@ -33,10 +44,23 @@ func main() { panic(err) } - fmt.Printf("total results: %d\n", len(results)) + fmt.Println("scan finished") } ``` The SDK currently reuses fscan's existing scan core and plugin registry. Calls are serialized internally because the current core still keeps process-wide runtime state. By default, the SDK runs a conservative service-oriented plugin set and blocks plugins with local side effects or active POC behavior. Set `AllowUnsafePlugins` only when the embedding system explicitly wants those capabilities. + +## API surface + +| Area | API | +| --- | --- | +| Scanning | `NewScanner`, `Scan`, `ScanEach` | +| Configuration | `Config`, `Target`, `CredentialPair`, `ValidateConfig` | +| Plugins | `DefaultSafePlugins`, `ListPlugins`, `GetPlugin`, `IsSafePlugin`, `PluginInfo` | +| Results | `Result`, `ResultTypeHost`, `ResultTypePort`, `ResultTypeService`, `ResultTypeVuln` | +| Result helpers | `Port`, `Service`, `Plugin`, `Username`, `Password`, `Banner`, `Vulnerability`, `URL`, `Protocol`, `IsWeb`, `IsCredential` | +| Summary | `SummarizeResults`, `ResultSummary.Add` | + +Use `Scan` when you want all results returned as a slice. Use `ScanEach` when results should be streamed into another system; handler calls are serialized, and returning an error stops the scan and returns that error. diff --git a/pkg/fscan/doc.go b/pkg/fscan/doc.go index ea441e6..98e2b20 100644 --- a/pkg/fscan/doc.go +++ b/pkg/fscan/doc.go @@ -4,4 +4,12 @@ // plugin registry, while hiding CLI flags, stdout output, and result files from // callers. The first SDK surface is serialized internally because the current // scan core still uses process-wide runtime state. +// +// Embedded callers can use ValidateConfig before starting a scan, IsSafePlugin +// or ListPlugins to build plugin allow lists, and ResultType* constants instead +// of matching raw result type strings. Result exposes helpers for common detail +// fields such as port, service, plugin, credentials, and web metadata. Use +// SummarizeResults or ResultSummary for aggregate counts. Use ScanEach for +// streaming consumption when callers do not want to retain the full result set +// in memory. package fscan diff --git a/pkg/fscan/result.go b/pkg/fscan/result.go new file mode 100644 index 0000000..3c7a87b --- /dev/null +++ b/pkg/fscan/result.go @@ -0,0 +1,218 @@ +package fscan + +import ( + "fmt" + "net" + "strconv" + "strings" +) + +// IsHost reports whether the result describes a live host. +func (r Result) IsHost() bool { return r.Type == ResultTypeHost } + +// IsPort reports whether the result describes an open port. +func (r Result) IsPort() bool { return r.Type == ResultTypePort } + +// IsService reports whether the result describes a service. +func (r Result) IsService() bool { return r.Type == ResultTypeService } + +// IsVuln reports whether the result describes a vulnerability or credential. +func (r Result) IsVuln() bool { return r.Type == ResultTypeVuln } + +// IsCredential reports whether the result describes a weak credential finding. +func (r Result) IsCredential() bool { + if resultType, ok := r.DetailString("type"); ok && resultType == "weak_credential" { + return true + } + return strings.HasPrefix(r.Status, "weak_credential:") +} + +// SummarizeResults counts common result categories. +func SummarizeResults(results []Result) ResultSummary { + var summary ResultSummary + for _, result := range results { + summary.Add(result) + } + return summary +} + +// Add includes one result in the summary. +func (s *ResultSummary) Add(result Result) { + s.Total++ + switch { + case result.IsHost(): + s.Hosts++ + case result.IsPort(): + s.Ports++ + case result.IsService(): + s.Services++ + case result.IsVuln(): + s.Vulns++ + } + if result.IsWeb() { + s.Web++ + } + if result.IsCredential() { + s.Credentials++ + } +} + +// DetailString returns a string detail value. +func (r Result) DetailString(key string) (string, bool) { + value, ok := r.Details[key] + if !ok || value == nil { + return "", false + } + switch v := value.(type) { + case string: + return v, true + case fmt.Stringer: + return v.String(), true + default: + return fmt.Sprint(v), true + } +} + +// DetailInt returns an integer detail value. +func (r Result) DetailInt(key string) (int, bool) { + value, ok := r.Details[key] + if !ok || value == nil { + return 0, false + } + switch v := value.(type) { + case int: + return v, true + case int8: + return int(v), true + case int16: + return int(v), true + case int32: + return int(v), true + case int64: + return intFromInt64(v) + case uint: + return intFromUint64(uint64(v)) + case uint8: + return int(v), true + case uint16: + return int(v), true + case uint32: + return intFromUint64(uint64(v)) + case uint64: + return intFromUint64(v) + case float32: + return intFromFloat64(float64(v)) + case float64: + return intFromFloat64(v) + case string: + n, err := strconv.Atoi(strings.TrimSpace(v)) + return n, err == nil + case fmt.Stringer: + n, err := strconv.Atoi(strings.TrimSpace(v.String())) + return n, err == nil + default: + return 0, false + } +} + +// DetailBool returns a boolean detail value. +func (r Result) DetailBool(key string) (bool, bool) { + value, ok := r.Details[key] + if !ok || value == nil { + return false, false + } + switch v := value.(type) { + case bool: + return v, true + case string: + b, err := strconv.ParseBool(strings.TrimSpace(v)) + return b, err == nil + default: + return false, false + } +} + +// Port returns the result port from details, or from a target in host:port form. +func (r Result) Port() (int, bool) { + if port, ok := r.DetailInt("port"); ok { + return port, true + } + if _, portText, err := net.SplitHostPort(r.Target); err == nil { + port, err := strconv.Atoi(portText) + return port, err == nil + } + if strings.Count(r.Target, ":") == 1 { + if idx := strings.LastIndex(r.Target, ":"); idx >= 0 && idx+1 < len(r.Target) { + port, err := strconv.Atoi(r.Target[idx+1:]) + return port, err == nil + } + } + return 0, false +} + +// Service returns the detected service name when present. +func (r Result) Service() (string, bool) { return r.DetailString("service") } + +// Plugin returns the plugin that produced the result when present. +func (r Result) Plugin() (string, bool) { return r.DetailString("plugin") } + +// Username returns the credential username when present. +func (r Result) Username() (string, bool) { return r.DetailString("username") } + +// Password returns the credential password when present. +func (r Result) Password() (string, bool) { return r.DetailString("password") } + +// Banner returns the service banner when present. +func (r Result) Banner() (string, bool) { return r.DetailString("banner") } + +// Vulnerability returns the vulnerability description when present. +func (r Result) Vulnerability() (string, bool) { return r.DetailString("vulnerability") } + +// URL returns the web result URL when present. +func (r Result) URL() (string, bool) { return r.DetailString("url") } + +// Protocol returns the detected protocol when present. +func (r Result) Protocol() (string, bool) { return r.DetailString("protocol") } + +// IsWeb reports whether the result is associated with an HTTP(S) service. +func (r Result) IsWeb() bool { + if ok, found := r.DetailBool("is_web"); found { + return ok + } + for _, getter := range []func() (string, bool){r.Service, r.Protocol} { + value, ok := getter() + if !ok { + continue + } + value = strings.ToLower(value) + if value == "http" || value == "https" { + return true + } + } + return false +} + +func intFromInt64(v int64) (int, bool) { + max := int64(^uint(0) >> 1) + min := -max - 1 + if v < min || v > max { + return 0, false + } + return int(v), true +} + +func intFromUint64(v uint64) (int, bool) { + max := uint64(^uint(0) >> 1) + if v > max { + return 0, false + } + return int(v), true +} + +func intFromFloat64(v float64) (int, bool) { + n := int64(v) + if float64(n) != v { + return 0, false + } + return intFromInt64(n) +} diff --git a/pkg/fscan/result_test.go b/pkg/fscan/result_test.go new file mode 100644 index 0000000..302171a --- /dev/null +++ b/pkg/fscan/result_test.go @@ -0,0 +1,122 @@ +package fscan + +import ( + "encoding/json" + "testing" +) + +func TestResultHelpers(t *testing.T) { + result := Result{ + Type: ResultTypeService, + Target: "127.0.0.1:8080", + Status: "identified", + Details: map[string]interface{}{ + "port": float64(8080), + "service": "http", + "plugin": "webtitle", + "banner": "nginx", + "is_web": "true", + "protocol": "http", + }, + } + + if !result.IsService() || result.IsPort() { + t.Fatalf("unexpected type helpers for %q", result.Type) + } + if port, ok := result.Port(); !ok || port != 8080 { + t.Fatalf("Port = %d/%v, want 8080/true", port, ok) + } + if service, ok := result.Service(); !ok || service != "http" { + t.Fatalf("Service = %q/%v, want http/true", service, ok) + } + if plugin, ok := result.Plugin(); !ok || plugin != "webtitle" { + t.Fatalf("Plugin = %q/%v, want webtitle/true", plugin, ok) + } + if banner, ok := result.Banner(); !ok || banner != "nginx" { + t.Fatalf("Banner = %q/%v, want nginx/true", banner, ok) + } + if !result.IsWeb() { + t.Fatal("expected web result") + } +} + +func TestResultPortFallback(t *testing.T) { + result := Result{Target: "[::1]:22"} + + port, ok := result.Port() + if !ok || port != 22 { + t.Fatalf("Port = %d/%v, want 22/true", port, ok) + } +} + +func TestResultPortDoesNotParseBareIPv6(t *testing.T) { + result := Result{Target: "2001:db8::1"} + + if port, ok := result.Port(); ok { + t.Fatalf("Port = %d/true, want false", port) + } +} + +func TestResultCredentialHelpers(t *testing.T) { + result := Result{ + Type: ResultTypeVuln, + Details: map[string]interface{}{ + "type": "weak_credential", + "username": "root", + "password": "toor", + }, + } + + if !result.IsVuln() { + t.Fatal("expected vuln result") + } + if !result.IsCredential() { + t.Fatal("expected credential result") + } + if username, ok := result.Username(); !ok || username != "root" { + t.Fatalf("Username = %q/%v, want root/true", username, ok) + } + if password, ok := result.Password(); !ok || password != "toor" { + t.Fatalf("Password = %q/%v, want toor/true", password, ok) + } +} + +func TestSummarizeResults(t *testing.T) { + results := []Result{ + {Type: ResultTypeHost, Target: "127.0.0.1"}, + {Type: ResultTypePort, Target: "127.0.0.1", Details: map[string]interface{}{"port": 80}}, + {Type: ResultTypeService, Target: "127.0.0.1:80", Details: map[string]interface{}{"service": "http"}}, + {Type: ResultTypeVuln, Target: "127.0.0.1:22", Status: "weak_credential: root:toor"}, + } + + summary := SummarizeResults(results) + if summary.Total != 4 { + t.Fatalf("Total = %d, want 4", summary.Total) + } + if summary.Hosts != 1 || summary.Ports != 1 || summary.Services != 1 || summary.Vulns != 1 { + t.Fatalf("summary categories = %#v, want one each", summary) + } + if summary.Web != 1 { + t.Fatalf("Web = %d, want 1", summary.Web) + } + if summary.Credentials != 1 { + t.Fatalf("Credentials = %d, want 1", summary.Credentials) + } +} + +func TestResultDetailIntRejectsFraction(t *testing.T) { + result := Result{Details: map[string]interface{}{"port": 22.5}} + + if port, ok := result.DetailInt("port"); ok { + t.Fatalf("DetailInt = %d/true, want false", port) + } +} + +func TestResultDetailIntParsesJSONNumber(t *testing.T) { + result := Result{Details: map[string]interface{}{"port": json.Number("443")}} + + port, ok := result.DetailInt("port") + if !ok || port != 443 { + t.Fatalf("DetailInt = %d/%v, want 443/true", port, ok) + } +} diff --git a/pkg/fscan/scanner.go b/pkg/fscan/scanner.go index c04a5cc..b9f2bb4 100644 --- a/pkg/fscan/scanner.go +++ b/pkg/fscan/scanner.go @@ -89,9 +89,83 @@ func DefaultSafePlugins() []string { return append([]string(nil), defaultSafePlugins...) } +// ListPlugins returns metadata for all registered plugins, sorted by name. +func ListPlugins() []PluginInfo { + names := plugins.All() + sort.Strings(names) + + items := make([]PluginInfo, 0, len(names)) + for _, name := range names { + if info, ok := GetPlugin(name); ok { + items = append(items, info) + } + } + return items +} + +// GetPlugin returns metadata for a registered plugin. +func GetPlugin(name string) (PluginInfo, bool) { + name = strings.TrimSpace(name) + if name == "" || !plugins.Exists(name) { + return PluginInfo{}, false + } + return PluginInfo{ + Name: name, + Types: pluginTypes(name), + Ports: pluginPorts(name), + Safe: IsSafePlugin(name), + Default: isDefaultSafePlugin(name), + }, true +} + +// ValidateConfig checks whether a config and target set can be used for an +// embedded scan. If no targets are passed, Config.Targets is validated. +func ValidateConfig(config Config, targets ...Target) error { + if len(targets) == 0 { + targets = config.Targets + } + return validateConfig(config, targets) +} + +// IsSafePlugin reports whether a plugin may be used while AllowUnsafePlugins is +// false. Unknown plugin names are not safe. +func IsSafePlugin(name string) bool { + name = strings.TrimSpace(name) + if name == "" || !plugins.Exists(name) { + return false + } + if plugins.HasType(name, plugins.PluginTypeLocal) { + return false + } + if _, bad := unsafePlugins[name]; bad { + return false + } + return true +} + // Scan runs the scanner for the provided targets and returns structured // findings. If no targets are provided, Config.Targets is used. func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) { + var ( + mu sync.Mutex + results []Result + ) + err := s.ScanEach(ctx, func(result Result) error { + mu.Lock() + results = append(results, result) + mu.Unlock() + return nil + }, targets...) + return snapshotResults(&mu, results), err +} + +// ScanEach runs the scanner and calls handle serially for each structured +// result without retaining all results in memory. If handle returns an error, +// the scan context is canceled and that error is returned. +func (s *Scanner) ScanEach(ctx context.Context, handle ResultHandler, targets ...Target) error { + if handle == nil { + return fmt.Errorf("fscan: result handler is required") + } if ctx == nil { ctx = context.Background() } @@ -99,9 +173,12 @@ func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) targets = s.config.Targets } if err := validateConfig(s.config, targets); err != nil { - return nil, err + return err } + ctx, cancel := context.WithCancel(ctx) + defer cancel() + scanMu.Lock() defer scanMu.Unlock() @@ -109,31 +186,46 @@ func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) defer previous.restore() var ( - mu sync.Mutex - results []Result + errMu sync.Mutex + handleMu sync.Mutex + handlerErr error ) for _, target := range targets { if err := ctx.Err(); err != nil { - return snapshotResults(&mu, results), err + if stored := getHandlerError(&errMu, &handlerErr); stored != nil { + return stored + } + return err } sink := func(raw *output.ScanResult) error { if result, ok := convertOutputResult(raw); ok { - mu.Lock() - results = append(results, result) - mu.Unlock() + handleMu.Lock() + if err := handle(result); err != nil { + handleMu.Unlock() + setHandlerError(&errMu, &handlerErr, err) + cancel() + return err + } if s.config.OnResult != nil { s.config.OnResult(result) } + handleMu.Unlock() } return nil } if err := s.scanOne(ctx, target, sink); err != nil { - return snapshotResults(&mu, results), err + return err + } + if stored := getHandlerError(&errMu, &handlerErr); stored != nil { + return stored } } - return snapshotResults(&mu, results), ctx.Err() + if stored := getHandlerError(&errMu, &handlerErr); stored != nil { + return stored + } + return ctx.Err() } func (s *Scanner) scanOne(ctx context.Context, target Target, sink common.ResultSink) error { @@ -177,15 +269,11 @@ func validateConfig(config Config, targets []Target) error { if len(targets) == 0 { return fmt.Errorf("fscan: at least one target is required") } - for _, plugin := range config.Plugins { - name := strings.TrimSpace(plugin) - if name == "" { - continue - } + for _, name := range normalizePlugins(config.Plugins) { if !plugins.Exists(name) { return fmt.Errorf("fscan: plugin %q not found", name) } - if !config.AllowUnsafePlugins && !isSafePlugin(name) { + if !config.AllowUnsafePlugins && !IsSafePlugin(name) { return fmt.Errorf("fscan: plugin %q is not enabled for embedded safe mode", name) } } @@ -293,40 +381,16 @@ func buildFlagVars(config Config, target Target) *common.FlagVars { } func formatPlugins(config Config) string { - pluginNames := config.Plugins - if len(pluginNames) == 0 && !config.AllowUnsafePlugins { - pluginNames = defaultSafePlugins - } - if len(pluginNames) == 0 { - return "all" - } - parts := make([]string, 0, len(pluginNames)) - for _, plugin := range pluginNames { - plugin = strings.TrimSpace(plugin) - if plugin != "" { - parts = append(parts, plugin) - } - } + parts := normalizePlugins(config.Plugins) if len(parts) == 0 { - return "all" + if config.AllowUnsafePlugins { + return "all" + } + parts = defaultSafePlugins } return strings.Join(parts, ",") } -func isSafePlugin(name string) bool { - name = strings.TrimSpace(name) - if name == "" { - return true - } - if plugins.HasType(name, plugins.PluginTypeLocal) { - return false - } - if _, bad := unsafePlugins[name]; bad { - return false - } - return true -} - func formatPorts(ports []int) string { if len(ports) == 0 { return commonconfig.MainPorts @@ -340,6 +404,43 @@ func formatPorts(ports []int) string { return strings.Join(parts, ",") } +func normalizePlugins(pluginNames []string) []string { + parts := make([]string, 0, len(pluginNames)) + for _, plugin := range pluginNames { + plugin = strings.TrimSpace(plugin) + if plugin != "" { + parts = append(parts, plugin) + } + } + return parts +} + +func pluginTypes(name string) []string { + types := make([]string, 0, 3) + for _, pluginType := range []string{PluginTypeService, PluginTypeWeb, PluginTypeLocal} { + if plugins.HasType(name, pluginType) { + types = append(types, pluginType) + } + } + return types +} + +func pluginPorts(name string) []int { + ports := plugins.GetPluginPorts(name) + ports = append([]int(nil), ports...) + sort.Ints(ports) + return ports +} + +func isDefaultSafePlugin(name string) bool { + for _, plugin := range defaultSafePlugins { + if plugin == name { + return true + } + } + return false +} + func secondsOrDefault(value time.Duration, fallback int) int64 { if value <= 0 { return int64(fallback) @@ -371,6 +472,20 @@ func snapshotResults(mu *sync.Mutex, results []Result) []Result { return append([]Result(nil), results...) } +func setHandlerError(mu *sync.Mutex, target *error, err error) { + mu.Lock() + defer mu.Unlock() + if *target == nil { + *target = err + } +} + +func getHandlerError(mu *sync.Mutex, err *error) error { + mu.Lock() + defer mu.Unlock() + return *err +} + type runtimeSnapshot struct { flagVars common.FlagVars config *common.Config diff --git a/pkg/fscan/scanner_test.go b/pkg/fscan/scanner_test.go index 01e4a72..d4fcd47 100644 --- a/pkg/fscan/scanner_test.go +++ b/pkg/fscan/scanner_test.go @@ -2,6 +2,7 @@ package fscan import ( "context" + "errors" "net" "strings" "sync/atomic" @@ -29,6 +30,14 @@ func TestBuildFlagVarsDefaults(t *testing.T) { } } +func TestBuildFlagVarsBlankPluginsUseSafeDefaults(t *testing.T) { + fv := buildFlagVars(Config{Plugins: []string{" ", "\t"}}, Target{Host: "127.0.0.1"}) + + if fv.ScanMode != formatPlugins(Config{Plugins: DefaultSafePlugins()}) { + t.Fatalf("ScanMode = %q, want safe defaults", fv.ScanMode) + } +} + func TestBuildFlagVarsTargetPortsOverride(t *testing.T) { fv := buildFlagVars(Config{Ports: []int{22, 80}}, Target{Host: "127.0.0.1", Ports: []int{3306, 22}}) @@ -53,11 +62,57 @@ func TestValidateConfig(t *testing.T) { if err := validateConfig(Config{Plugins: []string{"webpoc"}, AllowUnsafePlugins: true}, []Target{{URL: "http://127.0.0.1"}}); err != nil { t.Fatalf("unsafe plugin with opt-in failed: %v", err) } + if err := ValidateConfig(Config{Targets: []Target{{Host: "127.0.0.1"}}}); err != nil { + t.Fatalf("ValidateConfig with config targets failed: %v", err) + } if err := validateConfig(Config{}, []Target{{Host: "127.0.0.1", Ports: []int{70000}}}); err == nil { t.Fatal("expected invalid port error") } } +func TestIsSafePlugin(t *testing.T) { + if !IsSafePlugin("ssh") { + t.Fatal("ssh should be safe") + } + if IsSafePlugin("webpoc") { + t.Fatal("webpoc should not be safe") + } + if IsSafePlugin("definitely-missing") { + t.Fatal("unknown plugin should not be safe") + } +} + +func TestListPlugins(t *testing.T) { + items := ListPlugins() + if len(items) == 0 { + t.Fatal("expected registered plugins") + } + for i := 1; i < len(items); i++ { + if items[i-1].Name > items[i].Name { + t.Fatalf("plugins not sorted: %q before %q", items[i-1].Name, items[i].Name) + } + } + ssh, ok := GetPlugin("ssh") + if !ok { + t.Fatal("missing ssh plugin") + } + if ssh.Name != "ssh" { + t.Fatalf("plugin name = %q, want ssh", ssh.Name) + } + if !ssh.Safe || !ssh.Default { + t.Fatalf("ssh safe/default = %v/%v, want true/true", ssh.Safe, ssh.Default) + } + if !containsString(ssh.Types, PluginTypeService) { + t.Fatalf("ssh types = %#v, want service", ssh.Types) + } + if !containsInt(ssh.Ports, 22) { + t.Fatalf("ssh ports = %#v, want 22", ssh.Ports) + } + if _, ok := GetPlugin("definitely-missing"); ok { + t.Fatal("unknown plugin should not exist") + } +} + func TestScanHonorsCanceledContext(t *testing.T) { scanner := NewScanner(Config{ DisablePing: true, @@ -76,28 +131,9 @@ func TestScanHonorsCanceledContext(t *testing.T) { } func TestScanCollectsResultsThroughSessionSink(t *testing.T) { - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } + listener := startFTPListener(t) defer listener.Close() - go func() { - for { - conn, err := listener.Accept() - if err != nil { - return - } - go func(conn net.Conn) { - defer conn.Close() - _ = conn.SetDeadline(time.Now().Add(2 * time.Second)) - _, _ = conn.Write([]byte("220 test FTP\r\n")) - buf := make([]byte, 64) - _, _ = conn.Read(buf) - }(conn) - } - }() - var callbackCalls int32 common.SetResultCallback(func(interface{}) { atomic.AddInt32(&callbackCalls, 1) @@ -130,10 +166,10 @@ func TestScanCollectsResultsThroughSessionSink(t *testing.T) { if got := atomic.LoadInt32(&streamed); got != int32(len(results)) { t.Fatalf("streamed length = %d, want %d", got, len(results)) } - if !hasResult(results, "PORT", "open", "") { + if !hasResult(results, ResultTypePort, "open", "") { t.Fatalf("missing port result: %#v", results) } - if !hasResult(results, "SERVICE", "FTP", "ftp") { + if !hasResult(results, ResultTypeService, "FTP", "ftp") { t.Fatalf("missing ftp plugin result: %#v", results) } if got := atomic.LoadInt32(&callbackCalls); got != 0 { @@ -141,6 +177,114 @@ func TestScanCollectsResultsThroughSessionSink(t *testing.T) { } } +func TestScanEachStreamsResults(t *testing.T) { + listener := startFTPListener(t) + defer listener.Close() + + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + + var results []Result + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + port := listener.Addr().(*net.TCPAddr).Port + err := scanner.ScanEach(ctx, func(result Result) error { + results = append(results, result) + return nil + }, Target{Host: "127.0.0.1", Ports: []int{port}}) + if err != nil { + t.Fatal(err) + } + if !hasResult(results, ResultTypePort, "open", "") { + t.Fatalf("missing port result: %#v", results) + } + if !hasResult(results, ResultTypeService, "FTP", "ftp") { + t.Fatalf("missing ftp plugin result: %#v", results) + } +} + +func TestScanEachReturnsHandlerError(t *testing.T) { + listener := startFTPListener(t) + defer listener.Close() + + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + + stopErr := errors.New("stop scan") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + port := listener.Addr().(*net.TCPAddr).Port + err := scanner.ScanEach(ctx, func(Result) error { + return stopErr + }, Target{Host: "127.0.0.1", Ports: []int{port}}) + if !errors.Is(err, stopErr) { + t.Fatalf("ScanEach error = %v, want %v", err, stopErr) + } +} + +func TestScanEachRequiresHandler(t *testing.T) { + scanner := NewScanner(Config{Targets: []Target{{Host: "127.0.0.1"}}}) + + if err := scanner.ScanEach(context.Background(), nil); err == nil { + t.Fatal("expected missing handler error") + } +} + +func startFTPListener(t *testing.T) net.Listener { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(2 * time.Second)) + _, _ = conn.Write([]byte("220 test FTP\r\n")) + buf := make([]byte, 64) + _, _ = conn.Read(buf) + }(conn) + } + }() + return listener +} + +func containsString(items []string, value string) bool { + for _, item := range items { + if item == value { + return true + } + } + return false +} + +func containsInt(items []int, value int) bool { + for _, item := range items { + if item == value { + return true + } + } + return false +} + func hasResult(results []Result, resultType, statusText, plugin string) bool { for _, result := range results { if result.Type != resultType || !strings.Contains(result.Status, statusText) { diff --git a/pkg/fscan/types.go b/pkg/fscan/types.go index 6148b22..1794eba 100644 --- a/pkg/fscan/types.go +++ b/pkg/fscan/types.go @@ -4,6 +4,26 @@ import ( "time" ) +const ( + // PluginTypeWeb marks web-facing plugins. + PluginTypeWeb = "web" + // PluginTypeLocal marks plugins that operate on the local host. + PluginTypeLocal = "local" + // PluginTypeService marks network service plugins. + PluginTypeService = "service" +) + +const ( + // ResultTypeHost reports a live host. + ResultTypeHost = "HOST" + // ResultTypePort reports an open port. + ResultTypePort = "PORT" + // ResultTypeService reports a service fingerprint or service plugin result. + ResultTypeService = "SERVICE" + // ResultTypeVuln reports a vulnerability or credential finding. + ResultTypeVuln = "VULN" +) + // Target describes one scan target. Use Host for host/IP/CIDR/range service // scans, or URL for web scans. Ports applies only to Host scans. type Target struct { @@ -18,6 +38,31 @@ type CredentialPair struct { Password string } +// PluginInfo describes one registered scanner plugin. +type PluginInfo struct { + Name string `json:"name"` + Types []string `json:"types,omitempty"` + Ports []int `json:"ports,omitempty"` + Safe bool `json:"safe"` + Default bool `json:"default"` +} + +// ResultSummary counts common result categories. +type ResultSummary struct { + Total int `json:"total"` + Hosts int `json:"hosts"` + Ports int `json:"ports"` + Services int `json:"services"` + Vulns int `json:"vulns"` + Web int `json:"web"` + Credentials int `json:"credentials"` +} + +// ResultHandler receives one structured result. Calls are serialized by the +// scanner. Returning an error asks the scanner to stop and returns that error +// to the caller. +type ResultHandler func(Result) error + // Config controls an embedded scan. Zero values use the same conservative // defaults as the CLI, except output is silent and file saving is disabled. type Config struct { From 3dde0c6a8ee246593f3b9c2e5e2ce2459fc9684d Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 18 May 2026 15:57:18 +0800 Subject: [PATCH 04/12] move plugin safety metadata to registry --- pkg/fscan/scanner.go | 31 +------------------------------ pkg/fscan/scanner_test.go | 10 ++++++++++ plugins/init.go | 34 +++++++++++++++++++++++++++++++++- plugins/web/types.go | 7 +++++++ plugins/web/webpoc.go | 2 +- 5 files changed, 52 insertions(+), 32 deletions(-) diff --git a/pkg/fscan/scanner.go b/pkg/fscan/scanner.go index b9f2bb4..c3e2ecf 100644 --- a/pkg/fscan/scanner.go +++ b/pkg/fscan/scanner.go @@ -50,29 +50,6 @@ var defaultSafePlugins = []string{ "webtitle", } -var unsafePlugins = map[string]struct{}{ - "cleaner": {}, - "crontask": {}, - "download": {}, - "forwardshell": {}, - "keylogger": {}, - "ldpreload": {}, - "minidump": {}, - "reverseshell": {}, - "socks5proxy": {}, - "sshkey": {}, - "systemdservice": {}, - "winbits": {}, - "winifeo": {}, - "winlogon": {}, - "winregistry": {}, - "winschtask": {}, - "winservice": {}, - "winstartup": {}, - "winwmi": {}, - "webpoc": {}, -} - // Scanner runs fscan from another Go process. type Scanner struct { config Config @@ -134,13 +111,7 @@ func IsSafePlugin(name string) bool { if name == "" || !plugins.Exists(name) { return false } - if plugins.HasType(name, plugins.PluginTypeLocal) { - return false - } - if _, bad := unsafePlugins[name]; bad { - return false - } - return true + return plugins.IsSafe(name) } // Scan runs the scanner for the provided targets and returns structured diff --git a/pkg/fscan/scanner_test.go b/pkg/fscan/scanner_test.go index d4fcd47..09b7e09 100644 --- a/pkg/fscan/scanner_test.go +++ b/pkg/fscan/scanner_test.go @@ -111,6 +111,16 @@ func TestListPlugins(t *testing.T) { if _, ok := GetPlugin("definitely-missing"); ok { t.Fatal("unknown plugin should not exist") } + webpoc, ok := GetPlugin("webpoc") + if !ok { + t.Fatal("missing webpoc plugin") + } + if webpoc.Safe { + t.Fatal("webpoc should be marked unsafe") + } + if !containsString(webpoc.Types, PluginTypeWeb) { + t.Fatalf("webpoc types = %#v, want web", webpoc.Types) + } } func TestScanHonorsCanceledContext(t *testing.T) { diff --git a/plugins/init.go b/plugins/init.go index c6987ff..7978af1 100644 --- a/plugins/init.go +++ b/plugins/init.go @@ -43,7 +43,7 @@ const ( type Result struct { Type ResultType Success bool - Skipped bool // 扫描被跳过,不应输出结果 + Skipped bool // 扫描被跳过,不应输出结果 Service string Username string Password string @@ -84,6 +84,7 @@ type PluginInfo struct { factory func() Plugin ports []int types []string // 插件类型标签 + safe bool // 是否适合默认嵌入式扫描 } // 插件类型常量 @@ -123,12 +124,23 @@ func RegisterWithPorts(name string, factory func() Plugin, ports []int) { // RegisterWithTypes 注册带类型标签的插件 func RegisterWithTypes(name string, factory func() Plugin, ports []int, types []string) { + RegisterWithOptions(name, factory, ports, types, !hasPluginType(types, PluginTypeLocal)) +} + +// RegisterUnsafeWithTypes 注册不适合默认嵌入式扫描的插件。 +func RegisterUnsafeWithTypes(name string, factory func() Plugin, ports []int, types []string) { + RegisterWithOptions(name, factory, ports, types, false) +} + +// RegisterWithOptions 注册带完整元数据的插件。 +func RegisterWithOptions(name string, factory func() Plugin, ports []int, types []string, safe bool) { mutex.Lock() defer mutex.Unlock() plugins[name] = &PluginInfo{ factory: factory, ports: ports, types: types, + safe: safe, } } @@ -147,6 +159,17 @@ func HasType(pluginName string, typeName string) bool { return false } +// IsSafe 检查插件是否适合默认嵌入式扫描。 +func IsSafe(pluginName string) bool { + mutex.RLock() + defer mutex.RUnlock() + + if info, exists := plugins[pluginName]; exists { + return info.safe + } + return false +} + // Get 获取插件实例 func Get(name string) Plugin { mutex.RLock() @@ -190,6 +213,15 @@ func GetPluginPorts(name string) []int { return []int{} // 返回空列表表示适用于所有端口 } +func hasPluginType(types []string, typeName string) bool { + for _, t := range types { + if t == typeName { + return true + } + } + return false +} + // GenerateCredentials 生成测试凭据 func GenerateCredentials(service string, config *common.Config) []Credential { var credentials []Credential diff --git a/plugins/web/types.go b/plugins/web/types.go index 7882ec4..483743b 100644 --- a/plugins/web/types.go +++ b/plugins/web/types.go @@ -22,3 +22,10 @@ func RegisterWebPlugin(name string, creator func() WebPlugin) { return creator() }, []int{}, []string{plugins.PluginTypeWeb}) } + +// RegisterUnsafeWebPlugin 注册需要显式授权的主动Web插件。 +func RegisterUnsafeWebPlugin(name string, creator func() WebPlugin) { + plugins.RegisterUnsafeWithTypes(name, func() plugins.Plugin { + return creator() + }, []int{}, []string{plugins.PluginTypeWeb}) +} diff --git a/plugins/web/webpoc.go b/plugins/web/webpoc.go index 3b43e0b..e09e0c5 100644 --- a/plugins/web/webpoc.go +++ b/plugins/web/webpoc.go @@ -130,7 +130,7 @@ func matchCDNorWAF(fingerprints []string) string { // init 自动注册插件 func init() { - RegisterWebPlugin("webpoc", func() WebPlugin { + RegisterUnsafeWebPlugin("webpoc", func() WebPlugin { return NewWebPocPlugin() }) } From 8de757026859b323842967b9681030044136a51d Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 18 May 2026 16:11:41 +0800 Subject: [PATCH 05/12] allow concurrent embedded scans --- common/logger.go | 51 ++++++++++++---- pkg/fscan/README.md | 2 +- pkg/fscan/doc.go | 4 +- pkg/fscan/scanner.go | 44 ++------------ pkg/fscan/scanner_test.go | 123 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 171 insertions(+), 53 deletions(-) diff --git a/common/logger.go b/common/logger.go index a3f018c..23f2d5f 100644 --- a/common/logger.go +++ b/common/logger.go @@ -14,11 +14,16 @@ import ( ) var ( - globalLogger *logging.Logger - loggerOnce sync.Once + globalLogger *logging.Logger + loggerOnce sync.Once + loggerMu sync.Mutex + silentLoggerRefs int ) func getGlobalLogger() *logging.Logger { + loggerMu.Lock() + defer loggerMu.Unlock() + loggerOnce.Do(func() { fv := GetFlagVars() level := getLogLevelFromString(fv.LogLevel) @@ -27,7 +32,7 @@ func getGlobalLogger() *logging.Logger { EnableColor: !fv.NoColor, SlowOutput: false, ShowProgress: !fv.DisableProgress, - Silent: fv.Silent, + Silent: fv.Silent || silentLoggerRefs > 0, StartTime: GetGlobalState().GetStartTime(), } if fv.Debug { @@ -84,17 +89,41 @@ func LogError(errMsg string) { getGlobalLogger().Error(errMsg) } // CloseLogger 关闭日志系统,释放文件资源 func CloseLogger() { - if globalLogger != nil { - globalLogger.Close() + loggerMu.Lock() + defer loggerMu.Unlock() + closeLoggerLocked() +} + +// PushSilentLogger suppresses process-wide legacy log output until the returned +// restore function is called. It is reference counted so concurrent embedded +// scans can overlap safely. +func PushSilentLogger() func() { + loggerMu.Lock() + silentLoggerRefs++ + resetLoggerLocked() + loggerMu.Unlock() + + var once sync.Once + return func() { + once.Do(func() { + loggerMu.Lock() + if silentLoggerRefs > 0 { + silentLoggerRefs-- + } + resetLoggerLocked() + loggerMu.Unlock() + }) } } -// ResetLogger clears the process-wide logger so embedded callers can rebuild it -// after replacing runtime configuration. -func ResetLogger() { - if globalLogger != nil { - globalLogger.Close() - } +func resetLoggerLocked() { + closeLoggerLocked() globalLogger = nil loggerOnce = sync.Once{} } + +func closeLoggerLocked() { + if globalLogger != nil { + globalLogger.Close() + } +} diff --git a/pkg/fscan/README.md b/pkg/fscan/README.md index e476884..63065d7 100644 --- a/pkg/fscan/README.md +++ b/pkg/fscan/README.md @@ -48,7 +48,7 @@ func main() { } ``` -The SDK currently reuses fscan's existing scan core and plugin registry. Calls are serialized internally because the current core still keeps process-wide runtime state. +The SDK currently reuses fscan's existing scan core and plugin registry. Embedded scans build per-session runtime state and can run concurrently. By default, the SDK runs a conservative service-oriented plugin set and blocks plugins with local side effects or active POC behavior. Set `AllowUnsafePlugins` only when the embedding system explicitly wants those capabilities. diff --git a/pkg/fscan/doc.go b/pkg/fscan/doc.go index 98e2b20..75e95cf 100644 --- a/pkg/fscan/doc.go +++ b/pkg/fscan/doc.go @@ -2,8 +2,8 @@ // // The package is intentionally thin: it reuses the existing scan core and // plugin registry, while hiding CLI flags, stdout output, and result files from -// callers. The first SDK surface is serialized internally because the current -// scan core still uses process-wide runtime state. +// callers. Embedded scans build per-session runtime state and can run +// concurrently. // // Embedded callers can use ValidateConfig before starting a scan, IsSafePlugin // or ListPlugins to build plugin allow lists, and ResultType* constants instead diff --git a/pkg/fscan/scanner.go b/pkg/fscan/scanner.go index c3e2ecf..9aedaf4 100644 --- a/pkg/fscan/scanner.go +++ b/pkg/fscan/scanner.go @@ -21,8 +21,6 @@ import ( _ "github.com/shadow1ng/fscan/plugins/web" ) -var scanMu sync.Mutex - var defaultSafePlugins = []string{ "activemq", "cassandra", @@ -149,12 +147,8 @@ func (s *Scanner) ScanEach(ctx context.Context, handle ResultHandler, targets .. ctx, cancel := context.WithCancel(ctx) defer cancel() - - scanMu.Lock() - defer scanMu.Unlock() - - previous := captureRuntime() - defer previous.restore() + restoreLogger := common.PushSilentLogger() + defer restoreLogger() var ( errMu sync.Mutex @@ -201,13 +195,11 @@ func (s *Scanner) ScanEach(ctx context.Context, handle ResultHandler, targets .. func (s *Scanner) scanOne(ctx context.Context, target Target, sink common.ResultSink) error { fv := buildFlagVars(s.config, target) - globalFV := common.GetFlagVars() - *globalFV = *fv info := common.HostInfo{Host: strings.TrimSpace(target.Host), URL: strings.TrimSpace(target.URL)} - i18n.SetLanguage(globalFV.Language) + i18n.SetLanguage(fv.Language) - cfg, state, err := common.BuildConfig(globalFV, &info) + cfg, state, err := common.BuildConfig(fv, &info) if err != nil { return err } @@ -225,12 +217,7 @@ func (s *Scanner) scanOne(ctx context.Context, target Target, sink common.Result cfg.Output.DisableProgress = true cfg.Output.ShowProgress = false - common.SetGlobalConfig(cfg) - common.SetGlobalState(state) - common.ResetLogger() - common.InitLogger() - - session := common.NewScanSession(cfg, state, globalFV) + session := common.NewScanSession(cfg, state, fv) session.ResultSink = sink core.RunScan(ctx, info, session) return nil @@ -456,24 +443,3 @@ func getHandlerError(mu *sync.Mutex, err *error) error { defer mu.Unlock() return *err } - -type runtimeSnapshot struct { - flagVars common.FlagVars - config *common.Config - state *common.State -} - -func captureRuntime() runtimeSnapshot { - return runtimeSnapshot{ - flagVars: *common.GetFlagVars(), - config: common.GetGlobalConfig(), - state: common.GetGlobalState(), - } -} - -func (s runtimeSnapshot) restore() { - *common.GetFlagVars() = s.flagVars - common.SetGlobalConfig(s.config) - common.SetGlobalState(s.state) - common.ResetLogger() -} diff --git a/pkg/fscan/scanner_test.go b/pkg/fscan/scanner_test.go index 09b7e09..0862255 100644 --- a/pkg/fscan/scanner_test.go +++ b/pkg/fscan/scanner_test.go @@ -5,6 +5,7 @@ import ( "errors" "net" "strings" + "sync" "sync/atomic" "testing" "time" @@ -252,6 +253,128 @@ func TestScanEachRequiresHandler(t *testing.T) { } } +func TestScanEachRunsConcurrent(t *testing.T) { + first := startFTPListener(t) + defer first.Close() + second := startFTPListener(t) + defer second.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + blocked := make(chan struct{}) + release := make(chan struct{}) + firstErr := make(chan error, 1) + var blockOnce sync.Once + + go func() { + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + port := first.Addr().(*net.TCPAddr).Port + firstErr <- scanner.ScanEach(ctx, func(result Result) error { + if result.IsPort() { + blockOnce.Do(func() { close(blocked) }) + select { + case <-release: + case <-ctx.Done(): + return ctx.Err() + } + } + return nil + }, Target{Host: "127.0.0.1", Ports: []int{port}}) + }() + + select { + case <-blocked: + case <-time.After(2 * time.Second): + t.Fatal("first scan did not reach handler") + } + + secondErr := make(chan error, 1) + var secondResults []Result + go func() { + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + port := second.Addr().(*net.TCPAddr).Port + secondErr <- scanner.ScanEach(ctx, func(result Result) error { + secondResults = append(secondResults, result) + return nil + }, Target{Host: "127.0.0.1", Ports: []int{port}}) + }() + + select { + case err := <-secondErr: + if err != nil { + close(release) + t.Fatalf("second scan failed: %v", err) + } + case <-time.After(2 * time.Second): + close(release) + t.Fatal("second scan blocked behind first scan") + } + if !hasResult(secondResults, ResultTypePort, "open", "") { + close(release) + t.Fatalf("missing second scan result: %#v", secondResults) + } + + close(release) + if err := <-firstErr; err != nil { + t.Fatalf("first scan failed: %v", err) + } +} + +func TestScanDoesNotReplaceGlobalRuntime(t *testing.T) { + listener := startFTPListener(t) + defer listener.Close() + + previousConfig := common.GetGlobalConfig() + previousState := common.GetGlobalState() + previousFlags := *common.GetFlagVars() + defer func() { + common.SetGlobalConfig(previousConfig) + common.SetGlobalState(previousState) + *common.GetFlagVars() = previousFlags + }() + + sentinelConfig := common.NewConfig() + sentinelState := common.NewState() + common.SetGlobalConfig(sentinelConfig) + common.SetGlobalState(sentinelState) + common.GetFlagVars().LogLevel = "sentinel" + + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + port := listener.Addr().(*net.TCPAddr).Port + if _, err := scanner.Scan(context.Background(), Target{Host: "127.0.0.1", Ports: []int{port}}); err != nil { + t.Fatal(err) + } + + if common.GetGlobalConfig() != sentinelConfig { + t.Fatal("SDK scan replaced global config") + } + if common.GetGlobalState() != sentinelState { + t.Fatal("SDK scan replaced global state") + } + if common.GetFlagVars().LogLevel != "sentinel" { + t.Fatal("SDK scan replaced global flags") + } +} + func startFTPListener(t *testing.T) net.Listener { t.Helper() From adb3ac5b746d784425cb06382b6683a3013dff77 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 18 May 2026 16:19:34 +0800 Subject: [PATCH 06/12] add session-aware scan logging --- common/session.go | 45 +++++++++++++++++++++++++++++++--- common/session_test.go | 32 ++++++++++++++++++++++++ core/icmp.go | 14 +++-------- core/port_scan.go | 54 ++++++++++++++++++++--------------------- core/scanner.go | 27 ++++++++++++--------- plugins/services/ftp.go | 18 ++++++++------ 6 files changed, 131 insertions(+), 59 deletions(-) create mode 100644 common/session_test.go diff --git a/common/session.go b/common/session.go index 97193cb..3295514 100644 --- a/common/session.go +++ b/common/session.go @@ -48,18 +48,57 @@ func (s *ScanSession) SaveResult(result *output.ScanResult) error { return SaveResult(result) } +func (s *ScanSession) loggingEnabled() bool { + return s == nil || s.Config == nil || !s.Config.Output.Silent +} + +// LogDebug writes through the session's logging policy. +func (s *ScanSession) LogDebug(msg string) { + if s.loggingEnabled() { + LogDebug(msg) + } +} + +// LogInfo writes through the session's logging policy. +func (s *ScanSession) LogInfo(msg string) { + if s.loggingEnabled() { + LogInfo(msg) + } +} + +// LogSuccess writes through the session's logging policy. +func (s *ScanSession) LogSuccess(result string) { + if s.loggingEnabled() { + LogSuccess(result) + } +} + +// LogVuln writes through the session's logging policy. +func (s *ScanSession) LogVuln(result string) { + if s.loggingEnabled() { + LogVuln(result) + } +} + +// LogError writes through the session's logging policy. +func (s *ScanSession) LogError(errMsg string) { + if s.loggingEnabled() { + LogError(errMsg) + } +} + // DialTCP 创建 TCP 连接,内含限速检查、代理、计数 func (s *ScanSession) DialTCP(ctx context.Context, network, address string, timeout time.Duration) (net.Conn, error) { // 检查发包限制 if ok, err := CanSendPacketWith(s.Config, s.State); !ok { - LogError(fmt.Sprintf("TCP连接 %s 受限: %s", address, err.Error())) + s.LogError(fmt.Sprintf("TCP连接 %s 受限: %s", address, err.Error())) return nil, fmt.Errorf("%s", i18n.Tr("network_rate_limited", err.Error())) } // 获取 dialer dialer, err := s.getDialer() if err != nil { - LogError(fmt.Sprintf("获取代理拨号器失败: %v", err)) + s.LogError(fmt.Sprintf("获取代理拨号器失败: %v", err)) s.State.IncrementTCPFailedPacketCount() return nil, err } @@ -67,7 +106,7 @@ func (s *ScanSession) DialTCP(ctx context.Context, network, address string, time conn, err := dialer.DialContext(ctx, network, address) if err != nil { s.State.IncrementTCPFailedPacketCount() - LogDebug(fmt.Sprintf("连接 %s 失败: %v", address, err)) + s.LogDebug(fmt.Sprintf("连接 %s 失败: %v", address, err)) return nil, err } diff --git a/common/session_test.go b/common/session_test.go new file mode 100644 index 0000000..895ed56 --- /dev/null +++ b/common/session_test.go @@ -0,0 +1,32 @@ +package common + +import "testing" + +func TestScanSessionLogMethodsHonorSilentConfig(t *testing.T) { + loggerMu.Lock() + silentLoggerRefs = 0 + resetLoggerLocked() + loggerMu.Unlock() + t.Cleanup(func() { + loggerMu.Lock() + silentLoggerRefs = 0 + resetLoggerLocked() + loggerMu.Unlock() + }) + + cfg := NewConfig() + cfg.Output.Silent = true + session := NewScanSession(cfg, NewState(), &FlagVars{}) + + session.LogDebug("debug") + session.LogInfo("info") + session.LogSuccess("success") + session.LogVuln("vuln") + session.LogError("error") + + loggerMu.Lock() + defer loggerMu.Unlock() + if globalLogger != nil { + t.Fatal("silent session log methods initialized global logger") + } +} diff --git a/core/icmp.go b/core/icmp.go index a800fba..2ac1966 100644 --- a/core/icmp.go +++ b/core/icmp.go @@ -106,7 +106,7 @@ func tcpSupplementaryProbe(ctx context.Context, allHosts []string, aliveHosts [] } // 提示用户正在进行 TCP 补充探测 - common.LogInfo(i18n.Tr("tcp_probe_low_icmp_rate", fmt.Sprintf("%.1f%%", responseRate*100), len(unrespondedHosts))) + session.LogInfo(i18n.Tr("tcp_probe_low_icmp_rate", fmt.Sprintf("%.1f%%", responseRate*100), len(unrespondedHosts))) // 执行 TCP 补充探测 tcpAliveHosts := runTcpProbeForHosts(ctx, unrespondedHosts, session) @@ -114,7 +114,7 @@ func tcpSupplementaryProbe(ctx context.Context, allHosts []string, aliveHosts [] // 合并结果 if len(tcpAliveHosts) > 0 { aliveHosts = append(aliveHosts, tcpAliveHosts...) - common.LogInfo(i18n.Tr("tcp_probe_found", len(tcpAliveHosts))) + session.LogInfo(i18n.Tr("tcp_probe_found", len(tcpAliveHosts))) } return aliveHosts @@ -157,10 +157,7 @@ func handleAliveHosts(chanHosts chan string, hostslist []string, isPing bool, al } _ = session.SaveResult(result) - // 保留原有的控制台输出 - if !config.Output.Silent { - common.LogInfo(i18n.Tr("host_alive", ip, protocol)) - } + session.LogInfo(i18n.Tr("host_alive", ip, protocol)) } livewg.Done() } @@ -730,7 +727,6 @@ func tcpProbeAlive(ctx context.Context, session *common.ScanSession, host string // runTcpProbeForHosts 对指定主机列表进行 TCP 补充探测 // 返回存活的主机列表 func runTcpProbeForHosts(ctx context.Context, hosts []string, session *common.ScanSession) []string { - config := session.Config if len(hosts) == 0 { return nil } @@ -773,9 +769,7 @@ func runTcpProbeForHosts(ctx context.Context, hosts []string, session *common.Sc } _ = session.SaveResult(result) - if !config.Output.Silent { - common.LogInfo(i18n.Tr("host_alive", h, "TCP")) - } + session.LogInfo(i18n.Tr("host_alive", h, "TCP")) } }(host) } diff --git a/core/port_scan.go b/core/port_scan.go index 30f815f..a2ec510 100644 --- a/core/port_scan.go +++ b/core/port_scan.go @@ -143,13 +143,13 @@ func (f *failedPortCollector) Count() int { func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout int64, session *common.ScanSession, stream chan<- string) []string { config := session.Config state := session.State - common.LogDebug(fmt.Sprintf("[PortScan] 开始: %d个主机, 线程数=%d", len(hosts), config.ThreadNum)) + session.LogDebug(fmt.Sprintf("[PortScan] 开始: %d个主机, 线程数=%d", len(hosts), config.ThreadNum)) // 大规模扫描预筛:跨多个 /24 时先做网段探活,跳过空网段 if len(hosts) > subnetProbeThreshold { hosts = probeSubnets(ctx, hosts, time.Duration(timeout)*time.Second, session) if len(hosts) == 0 { - common.LogInfo(i18n.GetText("port_scan_no_alive_subnet")) + session.LogInfo(i18n.GetText("port_scan_no_alive_subnet")) if stream != nil { close(stream) } @@ -160,13 +160,13 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout // 解析端口和排除端口 portList := parsers.ParsePort(ports) if len(portList) == 0 { - common.LogError(i18n.Tr("invalid_port", ports)) + session.LogError(i18n.Tr("invalid_port", ports)) if stream != nil { close(stream) } return nil } - common.LogDebug(fmt.Sprintf("[PortScan] 端口解析完成: %d个端口", len(portList))) + session.LogDebug(fmt.Sprintf("[PortScan] 端口解析完成: %d个端口", len(portList))) // 使用config中的排除端口配置 excludePorts := parsers.ParsePort(config.Target.ExcludePorts) @@ -177,25 +177,25 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout // 检查代理可靠性,如果存在全回显问题则警告 if common.IsProxyEnabled() && !common.IsProxyReliable() { - common.LogError("检测到代理存在全回显问题,端口扫描结果可能不准确") + session.LogError("检测到代理存在全回显问题,端口扫描结果可能不准确") } // 创建流式迭代器(O(1) 内存,端口喷洒策略) iter := NewSocketIterator(hosts, portList, exclude) totalTasks := iter.Total() - common.LogDebug(fmt.Sprintf("[PortScan] 总任务数: %d", totalTasks)) + session.LogDebug(fmt.Sprintf("[PortScan] 总任务数: %d", totalTasks)) // 使用传入的配置 threadNum := config.ThreadNum // 大规模扫描警告和线程数自动调整 if totalTasks > 100000 { - common.LogInfo(fmt.Sprintf("大规模扫描: %d 个目标 (%d主机 × %d端口)", totalTasks, len(hosts), len(portList))) + session.LogInfo(fmt.Sprintf("大规模扫描: %d 个目标 (%d主机 × %d端口)", totalTasks, len(hosts), len(portList))) // 如果任务数超过100万且线程数大于300,自动降低线程数 if totalTasks > 1000000 && threadNum > 300 { oldThreadNum := threadNum threadNum = 300 - common.LogInfo(fmt.Sprintf("自动调整线程数: %d -> %d (大规模扫描优化)", oldThreadNum, threadNum)) + session.LogInfo(fmt.Sprintf("自动调整线程数: %d -> %d (大规模扫描优化)", oldThreadNum, threadNum)) } } @@ -204,7 +204,7 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout description := fmt.Sprintf("端口扫描中(%d线程)", threadNum) common.InitProgressBar(int64(totalTasks), description) } - common.LogDebug("[PortScan] 进度条初始化完成") + session.LogDebug("[PortScan] 进度条初始化完成") // 初始化并发控制 to := time.Duration(timeout) * time.Second @@ -214,7 +214,7 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout failedCollector := &failedPortCollector{} var wg sync.WaitGroup - common.LogDebug(fmt.Sprintf("[PortScan] 开始创建线程池, size=%d", threadNum)) + session.LogDebug(fmt.Sprintf("[PortScan] 开始创建线程池, size=%d", threadNum)) // 创建自适应线程池(支持动态调整) pool, err := NewAdaptivePool(threadNum, func(task interface{}) { taskInfo, ok := task.(portScanTask) @@ -230,19 +230,19 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout common.UpdateProgressBar(1) }, state) if err != nil { - common.LogError(i18n.Tr("thread_pool_create_failed", err)) + session.LogError(i18n.Tr("thread_pool_create_failed", err)) if stream != nil { close(stream) } return nil } - common.LogDebug("[PortScan] 线程池创建成功") + session.LogDebug("[PortScan] 线程池创建成功") defer pool.Release() - common.LogDebug("[PortScan] 开始滑动窗口调度") + session.LogDebug("[PortScan] 开始滑动窗口调度") // 滑动窗口调度:维护固定数量的"飞行中"任务 slidingWindowSchedule(iter, pool, &wg, threadNum) - common.LogDebug("[PortScan] 滑动窗口调度完成") + session.LogDebug("[PortScan] 滑动窗口调度完成") // 收集结果 aliveAddrs := collector.GetAll() @@ -257,7 +257,7 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout common.FinishProgressBar() } - common.LogInfo(i18n.Tr("port_scan_complete", count)) + session.LogInfo(i18n.Tr("port_scan_complete", count)) // 检查扫描失败率,如果过高则警告用户 resourceErrors := state.GetResourceExhaustedCount() @@ -268,18 +268,18 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout if failureRate > 20 { // 失败率超过20%,严重警告 - common.LogError(i18n.Tr("scan_failure_rate_high", fmt.Sprintf("%.1f%%", failureRate), failedCount, totalTasks)) - common.LogError(i18n.GetText("scan_failure_reason")) - common.LogError(i18n.Tr("scan_reduce_threads_suggestion", threadNum)) + session.LogError(i18n.Tr("scan_failure_rate_high", fmt.Sprintf("%.1f%%", failureRate), failedCount, totalTasks)) + session.LogError(i18n.GetText("scan_failure_reason")) + session.LogError(i18n.Tr("scan_reduce_threads_suggestion", threadNum)) } else if failureRate > 5 { // 失败率5-20%,一般警告 - common.LogInfo(i18n.Tr("scan_partial_failure", fmt.Sprintf("%.1f%%", failureRate), failedCount, totalTasks)) - common.LogInfo(i18n.Tr("scan_reduce_threads_accuracy", threadNum)) + session.LogInfo(i18n.Tr("scan_partial_failure", fmt.Sprintf("%.1f%%", failureRate), failedCount, totalTasks)) + session.LogInfo(i18n.Tr("scan_reduce_threads_accuracy", threadNum)) } } if resourceErrors > 0 { - common.LogError(i18n.Tr("resource_exhausted_warning", resourceErrors)) + session.LogError(i18n.Tr("resource_exhausted_warning", resourceErrors)) } return aliveAddrs @@ -467,7 +467,7 @@ func scanSinglePort(ctx context.Context, host string, port int, addr string, ada // 步骤1.5:代理连接深度验证(防止透明代理/全回显代理的假连接问题) valid, verifyMethod := verifyProxyConnectionDeep(conn, addr) if !valid { - common.LogDebug(fmt.Sprintf("代理验证失败 %s: %s", addr, verifyMethod)) + session.LogDebug(fmt.Sprintf("代理验证失败 %s: %s", addr, verifyMethod)) _ = conn.Close() return } @@ -655,7 +655,7 @@ func processServiceResult(host string, port int, addr string, serviceInfo *Servi if serviceInfo == nil { // 服务识别失败,尝试 HTTP 回退探测 if !tryHTTPFallbackDetection(host, port, addr, config, session) { - common.LogInfo(i18n.Tr("port_open", addr)) + session.LogInfo(i18n.Tr("port_open", addr)) } return } @@ -677,7 +677,7 @@ func processServiceResult(host string, port int, addr string, serviceInfo *Servi Details: details, }) - common.LogInfo(buildServiceLogMessage(addr, serviceInfo, isWeb)) + session.LogInfo(buildServiceLogMessage(addr, serviceInfo, isWeb)) } // buildServiceDetails 构建服务详情 map @@ -745,7 +745,7 @@ func tryHTTPFallbackDetection(host string, port int, addr string, config *common Details: details, }) - common.LogInfo(i18n.Tr("port_open_http", addr)) + session.LogInfo(i18n.Tr("port_open_http", addr)) return true } @@ -790,7 +790,7 @@ func probeSubnets(ctx context.Context, hosts []string, timeout time.Duration, se return hosts } - common.LogInfo(fmt.Sprintf("网段预筛: %d 个 /24 子网, %d 个主机", len(subnets), len(hosts))) + session.LogInfo(fmt.Sprintf("网段预筛: %d 个 /24 子网, %d 个主机", len(subnets), len(hosts))) aliveSubnets := sync.Map{} var wg sync.WaitGroup @@ -872,7 +872,7 @@ done: } skipped := len(subnets) - aliveCount - common.LogInfo(fmt.Sprintf("网段预筛完成: %d 个存活 (网关命中 %d), %d 个跳过, 剩余 %d 主机", + session.LogInfo(fmt.Sprintf("网段预筛完成: %d 个存活 (网关命中 %d), %d 个跳过, 剩余 %d 主机", aliveCount, gwHits, skipped, len(result))) return result } diff --git a/core/scanner.go b/core/scanner.go index 5ef4032..3fc5148 100644 --- a/core/scanner.go +++ b/core/scanner.go @@ -87,7 +87,7 @@ func RunScan(ctx context.Context, info common.HostInfo, session *common.ScanSess // 初始化HTTP客户端(静默,无需日志) if err := lib.Inithttp(config); err != nil { - common.LogError(i18n.Tr("http_client_init_failed", err)) + session.LogError(i18n.Tr("http_client_init_failed", err)) return } @@ -107,22 +107,22 @@ func RunScan(ctx context.Context, info common.HostInfo, session *common.ScanSess // 检查是否有活跃的连接需要维持 if state.IsReverseShellActive() || state.IsSocks5ProxyActive() || state.IsForwardShellActive() { if state.IsReverseShellActive() { - common.LogInfo(i18n.GetText("active_reverse_shell")) + session.LogInfo(i18n.GetText("active_reverse_shell")) } if state.IsSocks5ProxyActive() { - common.LogInfo(i18n.GetText("active_socks5_proxy")) + session.LogInfo(i18n.GetText("active_socks5_proxy")) } if state.IsForwardShellActive() { - common.LogInfo(i18n.GetText("active_forward_shell")) + session.LogInfo(i18n.GetText("active_forward_shell")) } - common.LogInfo(i18n.GetText("press_ctrl_c_exit")) + session.LogInfo(i18n.GetText("press_ctrl_c_exit")) // 优雅等待信号或 context 取消(Web Stop) sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) select { case <-sigChan: - common.LogInfo(i18n.GetText("received_exit_signal")) + session.LogInfo(i18n.GetText("received_exit_signal")) case <-ctx.Done(): } cancel() @@ -130,18 +130,21 @@ func RunScan(ctx context.Context, info common.HostInfo, session *common.ScanSess } // 完成扫描 - finishScan(config, state) + finishScan(session) } // finishScan 完成扫描并输出结果 -func finishScan(config *common.Config, state *common.State) { +func finishScan(session *common.ScanSession) { + config := session.Config + state := session.State + // 确保进度条正确完成 if common.IsProgressActive() { common.FinishProgressBar() } // 输出扫描完成信息 - common.LogInfo(i18n.Tr("scan_task_complete", time.Since(state.GetStartTime()).Round(time.Millisecond), state.GetNum())) + session.LogInfo(i18n.Tr("scan_task_complete", time.Since(state.GetStartTime()).Round(time.Millisecond), state.GetNum())) // 输出性能统计 JSON(如果启用) if config.Output.PerfStats { @@ -262,7 +265,7 @@ func executeScanTask(ctx context.Context, session *common.ScanSession, pluginNam defer func() { // 捕获并记录任何可能的panic if r := recover(); r != nil { - common.LogError(i18n.Tr("plugin_panic", pluginName, target.Host, target.Port, r)) + session.LogError(i18n.Tr("plugin_panic", pluginName, target.Host, target.Port, r)) } // 更新统计和进度(任务真正完成时才更新) @@ -284,10 +287,10 @@ func executeScanTask(ctx context.Context, session *common.ScanSession, pluginNam savePluginResult(session, &target, pluginName, result) } else if result.Type == plugins.ResultTypeCredential { // 凭据测试完成但未发现弱密码,在error级别输出提示 - common.LogError(i18n.Tr("brute_no_weak_pass", target.Host, target.Port, pluginName)) + session.LogError(i18n.Tr("brute_no_weak_pass", target.Host, target.Port, pluginName)) } else if result.Error != nil { // 其他类型的错误 - common.LogError(i18n.Tr("plugin_scan_error", target.Host, target.Port, result.Error)) + session.LogError(i18n.Tr("plugin_scan_error", target.Host, target.Port, result.Error)) } } } diff --git a/plugins/services/ftp.go b/plugins/services/ftp.go index 55deb79..0bce563 100644 --- a/plugins/services/ftp.go +++ b/plugins/services/ftp.go @@ -28,13 +28,13 @@ func (p *FTPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co config := session.Config state := session.State if config.DisableBrute { - return p.identifyService(info, config, state) + return p.identifyService(info, session) } target := info.Target() // 优先检测匿名访问 - if result := p.testAnonymousAccess(ctx, info, config, state); result != nil && result.Success { + if result := p.testAnonymousAccess(ctx, info, session); result != nil && result.Success { return result } @@ -63,7 +63,7 @@ func (p *FTPPlugin) Scan(ctx context.Context, info *common.HostInfo, session *co output.WriteString(fmt.Sprintf("\n [->] %s", file)) } } - common.LogVuln(output.String()) + session.LogVuln(output.String()) } return result @@ -144,7 +144,9 @@ func classifyFTPErrorType(err error) ErrorType { return ClassifyError(err, ftpAuthErrors, ftpNetworkErrors) } -func (p *FTPPlugin) identifyService(info *common.HostInfo, config *common.Config, state *common.State) *ScanResult { +func (p *FTPPlugin) identifyService(info *common.HostInfo, session *common.ScanSession) *ScanResult { + config := session.Config + state := session.State target := info.Target() conn, err := ftplib.Dial(target, ftplib.DialWithTimeout(config.Timeout)) @@ -160,7 +162,7 @@ func (p *FTPPlugin) identifyService(info *common.HostInfo, config *common.Config defer func() { _ = conn.Quit() }() banner := "FTP" - common.LogSuccess(i18n.Tr("ftp_service", target, banner)) + session.LogSuccess(i18n.Tr("ftp_service", target, banner)) return &ScanResult{ Type: plugins.ResultTypeService, Success: true, @@ -170,7 +172,9 @@ func (p *FTPPlugin) identifyService(info *common.HostInfo, config *common.Config } // testAnonymousAccess 测试FTP匿名访问 -func (p *FTPPlugin) testAnonymousAccess(ctx context.Context, info *common.HostInfo, config *common.Config, state *common.State) *ScanResult { +func (p *FTPPlugin) testAnonymousAccess(ctx context.Context, info *common.HostInfo, session *common.ScanSession) *ScanResult { + config := session.Config + state := session.State target := info.Target() anonymousCreds := []Credential{ @@ -204,7 +208,7 @@ func (p *FTPPlugin) testAnonymousAccess(ctx context.Context, info *common.HostIn output.WriteString(fmt.Sprintf("\n [->] %s", file)) } } - common.LogVuln(output.String()) + session.LogVuln(output.String()) return &ScanResult{ Type: plugins.ResultTypeCredential, From 13f7997d162e495a46c8ba2c5b3f3a0ba96c2aa7 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 18 May 2026 16:33:47 +0800 Subject: [PATCH 07/12] isolate scan strategy runtime state --- core/alive_scanner.go | 13 +++++---- core/base_scan_strategy.go | 9 ++++--- core/local_scanner.go | 14 +++++----- core/service_scanner.go | 35 ++++++++++++------------ core/web_scanner.go | 55 ++++++++++++++------------------------ core/web_scanner_test.go | 55 +++++++++++++++++++++++++++++++++++++- 6 files changed, 109 insertions(+), 72 deletions(-) diff --git a/core/alive_scanner.go b/core/alive_scanner.go index a8a66ff..5b04f9c 100644 --- a/core/alive_scanner.go +++ b/core/alive_scanner.go @@ -57,7 +57,7 @@ func (s *AliveScanStrategy) Description() string { func (s *AliveScanStrategy) Execute(ctx context.Context, session *common.ScanSession, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) { // 验证扫描目标(需要同时检查 -h 和 -hf 参数) if info.Host == "" && session.Params.HostsFile == "" { - common.LogError(i18n.GetText("parse_error_target_empty")) + session.LogError(i18n.GetText("parse_error_target_empty")) return } @@ -65,7 +65,7 @@ func (s *AliveScanStrategy) Execute(ctx context.Context, session *common.ScanSes s.performAliveScan(ctx, info, session) // 输出统计信息 - s.outputStats() + s.outputStats(session) } // performAliveScan 执行存活探测 @@ -73,12 +73,12 @@ func (s *AliveScanStrategy) performAliveScan(ctx context.Context, info common.Ho // 解析目标主机 hosts, err := parsers.ParseIP(info.Host, session.Params.HostsFile, session.Params.ExcludeHosts) if err != nil { - common.LogError(i18n.Tr("parse_target_failed", err)) + session.LogError(i18n.Tr("parse_target_failed", err)) return } if len(hosts) == 0 { - common.LogError(i18n.GetText("parse_error_no_hosts")) + session.LogError(i18n.GetText("parse_error_no_hosts")) return } @@ -87,7 +87,6 @@ func (s *AliveScanStrategy) performAliveScan(ctx context.Context, info common.Ho s.stats.AliveHosts = 0 s.stats.DeadHosts = 0 - // 执行存活检测 aliveList := CheckLive(ctx, hosts, false, session) // 使用ICMP探测 @@ -103,10 +102,10 @@ func (s *AliveScanStrategy) performAliveScan(ctx context.Context, info common.Ho } // outputStats 输出统计信息(精简版) -func (s *AliveScanStrategy) outputStats() { +func (s *AliveScanStrategy) outputStats(session *common.ScanSession) { // 只输出存活主机列表,不输出冗余统计 for _, host := range s.stats.AliveHostList { - common.LogSuccess(fmt.Sprintf("alive %s", host)) + session.LogSuccess(fmt.Sprintf("alive %s", host)) } } diff --git a/core/base_scan_strategy.go b/core/base_scan_strategy.go index 0b5c69f..1861e46 100644 --- a/core/base_scan_strategy.go +++ b/core/base_scan_strategy.go @@ -170,7 +170,7 @@ func (b *BaseScanStrategy) isPluginPassesFilterType(pluginName string, isCustomM } // LogPluginInfo 输出插件信息 -func (b *BaseScanStrategy) LogPluginInfo(config *common.Config) { +func (b *BaseScanStrategy) LogPluginInfo(config *common.Config, session *common.ScanSession) { allPlugins, isCustomMode := b.GetPlugins(config) var prefix string @@ -189,6 +189,7 @@ func (b *BaseScanStrategy) LogPluginInfo(config *common.Config) { _ = allPlugins _ = isCustomMode _ = prefix + _ = session } // formatPluginList 格式化插件列表(超过5个时精简显示) @@ -205,14 +206,14 @@ func (b *BaseScanStrategy) ValidateConfiguration() error { } // LogScanStart 输出扫描开始信息(已精简,仅在非服务扫描模式下显示) -func (b *BaseScanStrategy) LogScanStart() { +func (b *BaseScanStrategy) LogScanStart(session *common.ScanSession) { // 服务扫描模式下不显示(插件信息已足够说明) // 仅在本地/Web等特殊模式下显示 switch b.filterType { case FilterLocal: - common.LogInfo(i18n.GetText("start_local_scan")) + session.LogInfo(i18n.GetText("start_local_scan")) case FilterWeb: - common.LogInfo(i18n.GetText("start_web_scan")) + session.LogInfo(i18n.GetText("start_web_scan")) } } diff --git a/core/local_scanner.go b/core/local_scanner.go index c132a8d..a33ee20 100644 --- a/core/local_scanner.go +++ b/core/local_scanner.go @@ -22,12 +22,12 @@ func NewLocalScanStrategy() *LocalScanStrategy { } // LogPluginInfo 重写以只显示通过-local指定的插件 -func (s *LocalScanStrategy) LogPluginInfo(config *common.Config) { +func (s *LocalScanStrategy) LogPluginInfo(config *common.Config, session *common.ScanSession) { localPlugin := config.LocalPlugin if localPlugin != "" { - common.LogInfo(i18n.Tr("local_plugin_info", localPlugin)) + session.LogInfo(i18n.Tr("local_plugin_info", localPlugin)) } else { - common.LogError(i18n.GetText("local_plugin_not_specified")) + session.LogError(i18n.GetText("local_plugin_not_specified")) } } @@ -46,24 +46,24 @@ func (s *LocalScanStrategy) Execute(ctx context.Context, session *common.ScanSes config := session.Config // 输出扫描开始信息 - s.LogScanStart() + s.LogScanStart(session) // 验证插件配置 if err := s.ValidateConfiguration(); err != nil { - common.LogError(err.Error()) + session.LogError(err.Error()) return } // 验证本地插件是否存在 if config.LocalPlugin != "" { if !plugins.Exists(config.LocalPlugin) { - common.LogError(i18n.Tr("local_plugin_not_found", config.LocalPlugin)) + session.LogError(i18n.Tr("local_plugin_not_found", config.LocalPlugin)) return } } // 输出插件信息 - s.LogPluginInfo(config) + s.LogPluginInfo(config, session) // 准备目标(本地扫描通常只有一个目标,即本机) targets := s.PrepareTargets(info) diff --git a/core/service_scanner.go b/core/service_scanner.go index 8924d0a..29f4270 100644 --- a/core/service_scanner.go +++ b/core/service_scanner.go @@ -25,27 +25,27 @@ func NewServiceScanStrategy() *ServiceScanStrategy { } // LogPluginInfo 重写以提供基于端口的插件过滤 -func (s *ServiceScanStrategy) LogPluginInfo(config *common.Config) { +func (s *ServiceScanStrategy) LogPluginInfo(config *common.Config, session *common.ScanSession) { // 需要从命令行参数获取端口信息来进行过滤 // 如果没有指定端口,使用默认端口进行过滤显示 ports := config.Target.Ports if ports == "" || ports == "all" { // 默认端口扫描:显示所有插件 - s.BaseScanStrategy.LogPluginInfo(config) + s.BaseScanStrategy.LogPluginInfo(config, session) } else { // 指定端口扫描:只显示匹配的插件 - s.showPluginsForSpecifiedPorts(config) + s.showPluginsForSpecifiedPorts(config, session) } } // showPluginsForSpecifiedPorts 显示指定端口的匹配插件 -func (s *ServiceScanStrategy) showPluginsForSpecifiedPorts(config *common.Config) { +func (s *ServiceScanStrategy) showPluginsForSpecifiedPorts(config *common.Config, session *common.ScanSession) { allPlugins, isCustomMode := s.GetPlugins(config) // 解析端口 ports := s.parsePortList(config.Target.Ports) if len(ports) == 0 { - s.BaseScanStrategy.LogPluginInfo(config) + s.BaseScanStrategy.LogPluginInfo(config, session) return } @@ -71,12 +71,12 @@ func (s *ServiceScanStrategy) showPluginsForSpecifiedPorts(config *common.Config if len(applicablePlugins) > 0 { pluginStr := formatPluginList(applicablePlugins) if isCustomMode { - common.LogInfo(i18n.Tr("service_plugin_custom", pluginStr)) + session.LogInfo(i18n.Tr("service_plugin_custom", pluginStr)) } else { - common.LogInfo(i18n.Tr("service_plugin_info", pluginStr)) + session.LogInfo(i18n.Tr("service_plugin_info", pluginStr)) } } else { - common.LogInfo(i18n.GetText("service_plugin_none")) + session.LogInfo(i18n.GetText("service_plugin_none")) } } @@ -118,21 +118,21 @@ func (s *ServiceScanStrategy) Execute(ctx context.Context, session *common.ScanS // 验证扫描目标(需要同时检查 -h 和 -hf 参数) if info.Host == "" && session.Params.HostsFile == "" { - common.LogError(i18n.GetText("parse_error_target_empty")) + session.LogError(i18n.GetText("parse_error_target_empty")) return } // 输出扫描开始信息 - s.LogScanStart() + s.LogScanStart(session) // 验证插件配置 if err := s.ValidateConfiguration(); err != nil { - common.LogError(err.Error()) + session.LogError(err.Error()) return } // 输出插件信息(重写以提供端口过滤) - s.LogPluginInfo(config) + s.LogPluginInfo(config, session) // 执行主机扫描流程 s.performHostScan(ctx, session, info, ch, wg) @@ -147,14 +147,14 @@ func (s *ServiceScanStrategy) performHostScan(ctx context.Context, session *comm // 解析目标主机 hosts, err := parsers.ParseIP(info.Host, session.Params.HostsFile, session.Params.ExcludeHosts) if err != nil { - common.LogError(fmt.Sprintf("%s: %v", i18n.GetText("parse_target_failed"), err)) + session.LogError(fmt.Sprintf("%s: %v", i18n.GetText("parse_target_failed"), err)) return } // 主机存活检测 if s.shouldPerformLivenessCheck(hosts, config) { hosts = CheckLive(ctx, hosts, false, session) - common.LogInfo(i18n.Tr("alive_hosts_count_info", len(hosts))) + session.LogInfo(i18n.Tr("alive_hosts_count_info", len(hosts))) } if len(hosts) == 0 && len(state.GetHostPorts()) == 0 { @@ -218,7 +218,7 @@ func (s *ServiceScanStrategy) PrepareTargets(info common.HostInfo, session *comm // 发现目标主机和端口 targetInfos, err := s.discoverTargets(context.Background(), info.Host, info, session) if err != nil { - common.LogError(err.Error()) + session.LogError(err.Error()) return nil } return targetInfos @@ -291,7 +291,7 @@ func (s *ServiceScanStrategy) discoverTargets(ctx context.Context, hostInput str // 主机存活检测 if s.shouldPerformLivenessCheck(hosts, config) { hosts = CheckLive(ctx, hosts, false, session) - common.LogInfo(i18n.Tr("alive_hosts_count_info", len(hosts))) + session.LogInfo(i18n.Tr("alive_hosts_count_info", len(hosts))) } // 端口扫描 @@ -325,7 +325,7 @@ func (s *ServiceScanStrategy) discoverAlivePorts(ctx context.Context, hosts []st hostPorts := state.GetHostPorts() if len(hostPorts) > 0 { alivePorts = mergeHostPorts(alivePorts, hostPorts) - common.LogInfo(i18n.Tr("alive_ports_count", len(alivePorts))) + session.LogInfo(i18n.Tr("alive_ports_count", len(alivePorts))) state.ClearHostPorts() } @@ -390,4 +390,3 @@ func (s *ServiceScanStrategy) convertToTargetInfos(ports []string, baseInfo comm return infos } - diff --git a/core/web_scanner.go b/core/web_scanner.go index ff6dcbe..ad3463e 100644 --- a/core/web_scanner.go +++ b/core/web_scanner.go @@ -16,29 +16,6 @@ import ( gmtls "github.com/tjfoc/gmsm/gmtls" ) -// =============================== -// Web服务检测 -// =============================== - -// 全局共享 HTTP Client,复用连接池减少 TLS 握手和 TCP 建连开销 -var ( - sharedHTTPClientOnce sync.Once - sharedHTTPClient *http.Client -) - -func getSharedHTTPClient(config *common.Config) *http.Client { - sharedHTTPClientOnce.Do(func() { - sharedHTTPClient = createHTTPClient(config) - // 启用 keep-alive 复用连接 - if t, ok := sharedHTTPClient.Transport.(*http.Transport); ok { - t.DisableKeepAlives = false - t.MaxIdleConns = 100 - t.MaxIdleConnsPerHost = 2 - } - }) - return sharedHTTPClient -} - // WebPortDetector 简化的Web检测器 - 保持API兼容 type WebPortDetector struct{} @@ -91,7 +68,7 @@ func DetectHTTPScheme(host string, port int, config *common.Config, session *com } // TLS和GM TLS都失败,尝试HTTP - client := getSharedHTTPClient(config) + client := createHTTPClient(config, session) // 使用HEAD请求(更轻量) httpURL := fmt.Sprintf("http://%s", addr) @@ -106,7 +83,7 @@ func DetectHTTPScheme(host string, port int, config *common.Config, session *com } // createHTTPClient 创建统一的HTTP客户端 - 支持HTTP/HTTPS和代理 -func createHTTPClient(config *common.Config) *http.Client { +func createHTTPClient(config *common.Config, session *common.ScanSession) *http.Client { timeout := config.Network.WebTimeout // 创建基础Transport,配置连接和 TLS 超时 @@ -128,14 +105,14 @@ func createHTTPClient(config *common.Config) *http.Client { if proxyURL, err := url.Parse(networkConfig.HTTPProxy); err == nil { transport.Proxy = http.ProxyURL(proxyURL) } else { - common.LogError(i18n.Tr("http_proxy_config_error", err)) + session.LogError(i18n.Tr("http_proxy_config_error", err)) } } else if networkConfig.Socks5Proxy != "" { // 使用SOCKS5代理 - 需要特殊处理 if _, err := url.Parse(networkConfig.Socks5Proxy); err == nil { // SOCKS5代理需要使用代理管理器 // 这里先记录警告,建议使用HTTP代理进行Web检测 - common.LogError(i18n.GetText("socks5_not_supported_web")) + session.LogError(i18n.GetText("socks5_not_supported_web")) } } @@ -156,7 +133,7 @@ func (w *WebPortDetector) DetectHTTPServiceOnly(host string, port int, config *c return false } - client := getSharedHTTPClient(config) + client := createHTTPClient(config, session) // 尝试HTTP if w.tryHTTP(client, host, port, "http") { @@ -330,19 +307,19 @@ func (s *WebScanStrategy) Description() string { // Execute 执行Web扫描策略 func (s *WebScanStrategy) Execute(ctx context.Context, session *common.ScanSession, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) { // 输出扫描开始信息 - s.LogScanStart() + s.LogScanStart(session) // 验证插件配置 if err := s.ValidateConfiguration(); err != nil { - common.LogError(err.Error()) + session.LogError(err.Error()) return } // 准备URL目标 - targets := s.PrepareTargets(info, session.State) + targets := s.prepareTargets(info, session.State, session) // 输出插件信息 - s.LogPluginInfo(session.Config) + s.LogPluginInfo(session.Config, session) // 执行扫描任务 ExecuteScanTasks(ctx, session, targets, s, ch, wg) @@ -350,12 +327,16 @@ func (s *WebScanStrategy) Execute(ctx context.Context, session *common.ScanSessi // PrepareTargets 准备URL目标列表 func (s *WebScanStrategy) PrepareTargets(baseInfo common.HostInfo, state *common.State) []common.HostInfo { + return s.prepareTargets(baseInfo, state, nil) +} + +func (s *WebScanStrategy) prepareTargets(baseInfo common.HostInfo, state *common.State, session *common.ScanSession) []common.HostInfo { var targetInfos []common.HostInfo // 首先从State获取URL目标 urls := state.GetURLs() for _, urlStr := range urls { - urlInfo := s.createTargetFromURL(baseInfo, urlStr) + urlInfo := s.createTargetFromURLWithSession(baseInfo, urlStr, session) if urlInfo != nil { targetInfos = append(targetInfos, *urlInfo) } @@ -363,7 +344,7 @@ func (s *WebScanStrategy) PrepareTargets(baseInfo common.HostInfo, state *common // 如果URLs为空但baseInfo.Url有值,使用baseInfo.URL if len(targetInfos) == 0 && baseInfo.URL != "" { - urlInfo := s.createTargetFromURL(baseInfo, baseInfo.URL) + urlInfo := s.createTargetFromURLWithSession(baseInfo, baseInfo.URL, session) if urlInfo != nil { targetInfos = append(targetInfos, *urlInfo) } @@ -374,6 +355,10 @@ func (s *WebScanStrategy) PrepareTargets(baseInfo common.HostInfo, state *common // createTargetFromURL 从URL创建目标信息 func (s *WebScanStrategy) createTargetFromURL(baseInfo common.HostInfo, urlStr string) *common.HostInfo { + return s.createTargetFromURLWithSession(baseInfo, urlStr, nil) +} + +func (s *WebScanStrategy) createTargetFromURLWithSession(baseInfo common.HostInfo, urlStr string, session *common.ScanSession) *common.HostInfo { // 确保URL包含协议头 if !strings.HasPrefix(urlStr, "http://") && !strings.HasPrefix(urlStr, "https://") { urlStr = "http://" + urlStr @@ -382,7 +367,7 @@ func (s *WebScanStrategy) createTargetFromURL(baseInfo common.HostInfo, urlStr s // 解析URL获取Host和Port信息 parsedURL, err := url.Parse(urlStr) if err != nil { - common.LogError(i18n.Tr("url_parse_failed", urlStr, err)) + session.LogError(i18n.Tr("url_parse_failed", urlStr, err)) return nil } diff --git a/core/web_scanner_test.go b/core/web_scanner_test.go index ae5ae5e..1597a73 100644 --- a/core/web_scanner_test.go +++ b/core/web_scanner_test.go @@ -25,7 +25,6 @@ web_scanner_test.go - WebScanner核心逻辑测试 4. 指纹缓存 - SetFingerprints, GetFingerprints 不测试的部分(需要集成测试): -- createHTTPClient - 依赖全局配置 - tryHTTP, DetectHTTPServiceOnly - 网络IO - Execute - 完整流程 @@ -765,3 +764,57 @@ func TestDetectHTTPScheme(t *testing.T) { } }) } + +func TestCreateHTTPClientUsesPerSessionProxy(t *testing.T) { + cfgA := common.NewConfig() + cfgA.Network.WebTimeout = time.Second + cfgA.Network.HTTPProxy = "http://127.0.0.1:18080" + sessionA := common.NewScanSession(cfgA, common.NewState(), &common.FlagVars{}) + + cfgB := common.NewConfig() + cfgB.Network.WebTimeout = time.Second + cfgB.Network.HTTPProxy = "http://127.0.0.1:28080" + sessionB := common.NewScanSession(cfgB, common.NewState(), &common.FlagVars{}) + + clientA := createHTTPClient(cfgA, sessionA) + clientB := createHTTPClient(cfgB, sessionB) + if clientA == clientB { + t.Fatal("createHTTPClient reused a process-wide client") + } + + proxyA := proxyForTest(t, clientA) + proxyB := proxyForTest(t, clientB) + if proxyA == proxyB { + t.Fatalf("proxy URLs should be per config, both were %q", proxyA) + } + if proxyA != "http://127.0.0.1:18080" { + t.Fatalf("proxyA = %q, want http://127.0.0.1:18080", proxyA) + } + if proxyB != "http://127.0.0.1:28080" { + t.Fatalf("proxyB = %q, want http://127.0.0.1:28080", proxyB) + } +} + +func proxyForTest(t *testing.T, client *http.Client) string { + t.Helper() + + transport, ok := client.Transport.(*http.Transport) + if !ok { + t.Fatal("client transport is not *http.Transport") + } + if transport.Proxy == nil { + t.Fatal("client proxy is nil") + } + req, err := http.NewRequest(http.MethodGet, "http://example.com", nil) + if err != nil { + t.Fatal(err) + } + proxyURL, err := transport.Proxy(req) + if err != nil { + t.Fatal(err) + } + if proxyURL == nil { + t.Fatal("proxy URL is nil") + } + return proxyURL.String() +} From 856eeccd780021e2c109a1b0d0b3b9809e4b3d44 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 18 May 2026 16:35:58 +0800 Subject: [PATCH 08/12] respect per-call session dial timeouts --- common/session.go | 43 ++++++++++++++++++++++++++++-------------- common/session_test.go | 37 +++++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 15 deletions(-) diff --git a/common/session.go b/common/session.go index 3295514..757a5c9 100644 --- a/common/session.go +++ b/common/session.go @@ -24,10 +24,10 @@ type ScanSession struct { Params *FlagVars // 原始参数,只读 ResultSink ResultSink // 可选,覆盖全局输出 - // 每会话 dialer(懒初始化,取决于代理配置) - dialerOnce sync.Once - dialer proxy.Dialer - dialerErr error + // 每会话 dialer(按 timeout 懒初始化,取决于代理配置) + dialerMu sync.Mutex + dialers map[time.Duration]proxy.Dialer + dialerErrs map[time.Duration]error } // NewScanSession 从已构建的 Config、State 和 FlagVars 创建会话 @@ -96,7 +96,7 @@ func (s *ScanSession) DialTCP(ctx context.Context, network, address string, time } // 获取 dialer - dialer, err := s.getDialer() + dialer, err := s.getDialer(timeout) if err != nil { s.LogError(fmt.Sprintf("获取代理拨号器失败: %v", err)) s.State.IncrementTCPFailedPacketCount() @@ -119,18 +119,33 @@ func (s *ScanSession) DialTCP(ctx context.Context, network, address string, time return conn, nil } -func (s *ScanSession) getDialer() (proxy.Dialer, error) { - s.dialerOnce.Do(func() { - cfg := s.createProxyConfig() - manager := proxy.NewProxyManager(cfg) - s.dialer, s.dialerErr = manager.GetDialer() - }) - return s.dialer, s.dialerErr +func (s *ScanSession) getDialer(timeout time.Duration) (proxy.Dialer, error) { + if timeout <= 0 { + timeout = s.Config.Timeout + } + + s.dialerMu.Lock() + defer s.dialerMu.Unlock() + + if s.dialers == nil { + s.dialers = make(map[time.Duration]proxy.Dialer) + s.dialerErrs = make(map[time.Duration]error) + } + if dialer, ok := s.dialers[timeout]; ok { + return dialer, s.dialerErrs[timeout] + } + + cfg := s.createProxyConfig(timeout) + manager := proxy.NewProxyManager(cfg) + dialer, err := manager.GetDialer() + s.dialers[timeout] = dialer + s.dialerErrs[timeout] = err + return dialer, err } -func (s *ScanSession) createProxyConfig() *proxy.ProxyConfig { +func (s *ScanSession) createProxyConfig(timeout time.Duration) *proxy.ProxyConfig { cfg := proxy.DefaultProxyConfig() - cfg.Timeout = s.Config.Timeout + cfg.Timeout = timeout cfg.LocalAddr = s.Config.Network.Iface // 优先 SOCKS5 diff --git a/common/session_test.go b/common/session_test.go index 895ed56..f0eb601 100644 --- a/common/session_test.go +++ b/common/session_test.go @@ -1,6 +1,9 @@ package common -import "testing" +import ( + "testing" + "time" +) func TestScanSessionLogMethodsHonorSilentConfig(t *testing.T) { loggerMu.Lock() @@ -30,3 +33,35 @@ func TestScanSessionLogMethodsHonorSilentConfig(t *testing.T) { t.Fatal("silent session log methods initialized global logger") } } + +func TestScanSessionDialerCacheIsTimeoutAware(t *testing.T) { + cfg := NewConfig() + cfg.Timeout = 5 * time.Second + session := NewScanSession(cfg, NewState(), &FlagVars{}) + + shortTimeout := 100 * time.Millisecond + longTimeout := 2 * time.Second + + shortDialer, err := session.getDialer(shortTimeout) + if err != nil { + t.Fatal(err) + } + shortDialerAgain, err := session.getDialer(shortTimeout) + if err != nil { + t.Fatal(err) + } + longDialer, err := session.getDialer(longTimeout) + if err != nil { + t.Fatal(err) + } + + if shortDialer != shortDialerAgain { + t.Fatal("same timeout should reuse the session dialer") + } + if shortDialer == longDialer { + t.Fatal("different timeouts should not share one session dialer") + } + if got := session.createProxyConfig(shortTimeout).Timeout; got != shortTimeout { + t.Fatalf("proxy timeout = %v, want %v", got, shortTimeout) + } +} From c16aa04e28b562c73b2c8aca9af25cc489b38160 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 18 May 2026 17:11:24 +0800 Subject: [PATCH 09/12] isolate session network checks --- common/session.go | 38 ++++++++++++++++++++ common/session_test.go | 81 ++++++++++++++++++++++++++++++++++++++++++ core/port_scan.go | 10 +++--- core/web_scanner.go | 9 +++-- 4 files changed, 128 insertions(+), 10 deletions(-) diff --git a/common/session.go b/common/session.go index 757a5c9..d7e81d2 100644 --- a/common/session.go +++ b/common/session.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net" + "net/http" "strings" "sync" "time" @@ -119,6 +120,43 @@ func (s *ScanSession) DialTCP(ctx context.Context, network, address string, time return conn, nil } +// HTTPDo executes an HTTP request with the session's packet limits and counters. +func (s *ScanSession) HTTPDo(client *http.Client, req *http.Request) (*http.Response, error) { + if ok, err := CanSendPacketWith(s.Config, s.State); !ok { + s.LogError(fmt.Sprintf("HTTP请求 %s 受限: %s", req.URL.String(), err.Error())) + return nil, fmt.Errorf("%s", i18n.Tr("network_rate_limited", err.Error())) + } + + resp, err := client.Do(req) + if err != nil { + s.State.IncrementTCPFailedPacketCount() + return nil, err + } + s.State.IncrementTCPSuccessPacketCount() + return resp, nil +} + +// ProxyEnabled reports whether this scan session uses a network proxy. +func (s *ScanSession) ProxyEnabled() bool { + if s == nil || s.Config == nil { + return false + } + return s.Config.Network.Socks5Proxy != "" || s.Config.Network.HTTPProxy != "" +} + +// IsSOCKS5Proxy reports whether this scan session uses SOCKS5. +func (s *ScanSession) IsSOCKS5Proxy() bool { + return s != nil && s.Config != nil && s.Config.Network.Socks5Proxy != "" +} + +// ProxyReliable reports whether the session proxy should be treated as reliable. +func (s *ScanSession) ProxyReliable() bool { + if !s.ProxyEnabled() || !s.IsSOCKS5Proxy() { + return true + } + return proxy.IsProxyReliable() +} + func (s *ScanSession) getDialer(timeout time.Duration) (proxy.Dialer, error) { if timeout <= 0 { timeout = s.Config.Timeout diff --git a/common/session_test.go b/common/session_test.go index f0eb601..cb03d17 100644 --- a/common/session_test.go +++ b/common/session_test.go @@ -1,6 +1,9 @@ package common import ( + "io" + "net/http" + "strings" "testing" "time" ) @@ -65,3 +68,81 @@ func TestScanSessionDialerCacheIsTimeoutAware(t *testing.T) { t.Fatalf("proxy timeout = %v, want %v", got, shortTimeout) } } + +func TestScanSessionHTTPDoUsesSessionState(t *testing.T) { + previousState := GetGlobalState() + globalState := NewState() + SetGlobalState(globalState) + t.Cleanup(func() { SetGlobalState(previousState) }) + + sessionState := NewState() + session := NewScanSession(NewConfig(), sessionState, &FlagVars{}) + client := &http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNoContent, + Body: io.NopCloser(strings.NewReader("")), + Header: make(http.Header), + }, nil + }), + } + req, err := http.NewRequest(http.MethodHead, "http://example.com", nil) + if err != nil { + t.Fatal(err) + } + + resp, err := session.HTTPDo(client, req) + if err != nil { + t.Fatal(err) + } + _ = resp.Body.Close() + + if got := sessionState.GetTCPSuccessPacketCount(); got != 1 { + t.Fatalf("session TCP success count = %d, want 1", got) + } + if got := globalState.GetTCPSuccessPacketCount(); got != 0 { + t.Fatalf("global TCP success count = %d, want 0", got) + } +} + +func TestScanSessionProxyStateComesFromConfig(t *testing.T) { + direct := NewScanSession(NewConfig(), NewState(), &FlagVars{}) + if direct.ProxyEnabled() { + t.Fatal("direct session should not report proxy enabled") + } + if direct.IsSOCKS5Proxy() { + t.Fatal("direct session should not report SOCKS5") + } + if !direct.ProxyReliable() { + t.Fatal("direct session should be reliable") + } + + httpCfg := NewConfig() + httpCfg.Network.HTTPProxy = "http://127.0.0.1:8080" + httpSession := NewScanSession(httpCfg, NewState(), &FlagVars{}) + if !httpSession.ProxyEnabled() { + t.Fatal("HTTP proxy session should report proxy enabled") + } + if httpSession.IsSOCKS5Proxy() { + t.Fatal("HTTP proxy session should not report SOCKS5") + } + if !httpSession.ProxyReliable() { + t.Fatal("HTTP proxy session should be reliable") + } + + socksCfg := NewConfig() + socksCfg.Network.Socks5Proxy = "127.0.0.1:1080" + socksSession := NewScanSession(socksCfg, NewState(), &FlagVars{}) + if !socksSession.ProxyEnabled() { + t.Fatal("SOCKS5 proxy session should report proxy enabled") + } + if !socksSession.IsSOCKS5Proxy() { + t.Fatal("SOCKS5 proxy session should report SOCKS5") + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} diff --git a/core/port_scan.go b/core/port_scan.go index a2ec510..db81b5d 100644 --- a/core/port_scan.go +++ b/core/port_scan.go @@ -176,7 +176,7 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout } // 检查代理可靠性,如果存在全回显问题则警告 - if common.IsProxyEnabled() && !common.IsProxyReliable() { + if session.ProxyEnabled() && !session.ProxyReliable() { session.LogError("检测到代理存在全回显问题,端口扫描结果可能不准确") } @@ -465,7 +465,7 @@ func scanSinglePort(ctx context.Context, host string, port int, addr string, ada adaptiveTO.Record(time.Since(start)) // 步骤1.5:代理连接深度验证(防止透明代理/全回显代理的假连接问题) - valid, verifyMethod := verifyProxyConnectionDeep(conn, addr) + valid, verifyMethod := verifyProxyConnectionDeep(conn, addr, session) if !valid { session.LogDebug(fmt.Sprintf("代理验证失败 %s: %s", addr, verifyMethod)) _ = conn.Close() @@ -474,7 +474,7 @@ func scanSinglePort(ctx context.Context, host string, port int, addr string, ada // 步骤1.6:如果使用了代理且进行了数据交互,需要重建连接 // 因为验证阶段可能读取了Banner或发送了HTTP GET探测,污染了连接状态 - if common.IsProxyEnabled() && verifyMethod != "direct" { + if session.ProxyEnabled() && verifyMethod != "direct" { _ = conn.Close() // 重新建立干净的连接用于服务识别 conn, err = connectWithRetry(ctx, session, addr, timeout, 2) @@ -523,10 +523,10 @@ func handleConnectionFailure(err error, host string, port int, addr string, fail // 1. 快速 Banner 检测 (100ms) - 大部分服务会主动发送数据 // 2. 轻量探测 (发送 \r\n) - 触发某些服务响应,同时不污染协议状态 // 3. 短超时等待 (500ms) - 平衡准确性和性能 -func verifyProxyConnectionDeep(conn net.Conn, addr string) (bool, string) { +func verifyProxyConnectionDeep(conn net.Conn, addr string, session *common.ScanSession) (bool, string) { // 无代理或SOCKS5代理:跳过深度验证 // SOCKS5协议层已验证连接可达性,连接成功即端口开放 - if !common.IsProxyEnabled() || common.IsSOCKS5Proxy() { + if !session.ProxyEnabled() || session.IsSOCKS5Proxy() { return true, "direct" } diff --git a/core/web_scanner.go b/core/web_scanner.go index ad3463e..dd3c3ab 100644 --- a/core/web_scanner.go +++ b/core/web_scanner.go @@ -136,12 +136,12 @@ func (w *WebPortDetector) DetectHTTPServiceOnly(host string, port int, config *c client := createHTTPClient(config, session) // 尝试HTTP - if w.tryHTTP(client, host, port, "http") { + if w.tryHTTP(client, session, host, port, "http") { return true } // 尝试HTTPS - if w.tryHTTP(client, host, port, "https") { + if w.tryHTTP(client, session, host, port, "https") { return true } @@ -163,7 +163,7 @@ func isPortReachable(host string, port int, config *common.Config, session *comm } // tryHTTP 尝试HTTP请求 - 简化的核心逻辑 -func (w *WebPortDetector) tryHTTP(client *http.Client, host string, port int, protocol string) bool { +func (w *WebPortDetector) tryHTTP(client *http.Client, session *common.ScanSession, host string, port int, protocol string) bool { // 构造URL var url string if (port == 80 && protocol == "http") || (port == 443 && protocol == "https") { @@ -181,8 +181,7 @@ func (w *WebPortDetector) tryHTTP(client *http.Client, host string, port int, pr req.Header.Set("User-Agent", "fscan-web-detector/2.1") req.Header.Set("Accept", "*/*") - // 使用统一的SafeHTTPDo以确保遵循限速策略和代理设置 - resp, err := common.SafeHTTPDo(client, req) + resp, err := session.HTTPDo(client, req) if err != nil { return false } From c0a9cfd8f5762d3c873f7ab377bebc27f1f2474c Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 18 May 2026 17:13:19 +0800 Subject: [PATCH 10/12] use config proxy state for credential prechecks --- plugins/services/credential_tester.go | 18 +++++++------- plugins/services/credential_tester_test.go | 28 +++++++++++++++++++++- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/plugins/services/credential_tester.go b/plugins/services/credential_tester.go index 59e872f..8e4692f 100644 --- a/plugins/services/credential_tester.go +++ b/plugins/services/credential_tester.go @@ -107,11 +107,12 @@ func TestSingleCredential(ctx context.Context, cred Credential, authFn AuthFunc) // ConcurrentTestConfig 并发测试配置 type ConcurrentTestConfig struct { - Concurrency int // 并发数,默认 10 - MaxRetries int // 最大重试次数,默认 3 - RetryDelay time.Duration // 重试延迟,默认 1s - MaxConsecutiveNetErrors int // 连续网络错误阈值,超过则认为目标不可达,默认 5 - TargetAddr string // 目标地址 host:port,用于 TCP 预检(可选) + Concurrency int // 并发数,默认 10 + MaxRetries int // 最大重试次数,默认 3 + RetryDelay time.Duration // 重试延迟,默认 1s + MaxConsecutiveNetErrors int // 连续网络错误阈值,超过则认为目标不可达,默认 5 + TargetAddr string // 目标地址 host:port,用于 TCP 预检(可选) + UseProxy bool // 代理模式下跳过直连 TCP 预检 } // DefaultConcurrentTestConfig 默认配置 @@ -125,6 +126,7 @@ func DefaultConcurrentTestConfig(config *common.Config) ConcurrentTestConfig { MaxRetries: 3, RetryDelay: time.Second, MaxConsecutiveNetErrors: 5, + UseProxy: config.Network.Socks5Proxy != "" || config.Network.HTTPProxy != "", } } @@ -154,7 +156,7 @@ func TestCredentialsConcurrently( // TCP 预检:快速验证目标可达,避免对不可达目标浪费全部凭据尝试 // 代理模式下跳过:net.DialTimeout 直连无法到达代理后的内网目标 - if testConfig.TargetAddr != "" && !common.IsProxyEnabled() { + if testConfig.TargetAddr != "" && !testConfig.UseProxy { preConn, err := net.DialTimeout("tcp", testConfig.TargetAddr, 3*time.Second) if err != nil { return &ScanResult{ @@ -381,8 +383,8 @@ func ClassifyError(err error, authKeywords, networkKeywords []string) ErrorType func containsIgnoreCase(s, substr string) bool { return len(s) >= len(substr) && (s == substr || - len(substr) == 0 || - findIgnoreCase(s, substr) >= 0) + len(substr) == 0 || + findIgnoreCase(s, substr) >= 0) } // findIgnoreCase 忽略大小写查找子串 diff --git a/plugins/services/credential_tester_test.go b/plugins/services/credential_tester_test.go index 0d2ad1c..1955b11 100644 --- a/plugins/services/credential_tester_test.go +++ b/plugins/services/credential_tester_test.go @@ -268,6 +268,33 @@ func TestTestCredentialsConcurrently_EmptyCredentials(t *testing.T) { } } +func TestTestCredentialsConcurrently_ProxySkipsDirectPrecheck(t *testing.T) { + var calls atomic.Int32 + authFn := func(ctx context.Context, cred Credential) *AuthResult { + calls.Add(1) + return &AuthResult{ + Success: true, + Conn: &mockConn{}, + } + } + + config := ConcurrentTestConfig{ + Concurrency: 1, + MaxRetries: 1, + RetryDelay: time.Millisecond, + TargetAddr: "127.0.0.1:1", + UseProxy: true, + } + + result := TestCredentialsConcurrently(context.Background(), []Credential{{Username: "u", Password: "p"}}, authFn, "test", config) + if !result.Success { + t.Fatalf("proxy mode should skip direct precheck: %v", result.Error) + } + if calls.Load() == 0 { + t.Fatal("auth function was not called") + } +} + // TestTestCredentialsConcurrently_ContextCancel 测试context取消 func TestTestCredentialsConcurrently_ContextCancel(t *testing.T) { credentials := make([]Credential, 100) @@ -451,4 +478,3 @@ func TestRetryLogic_AuthErrorNoRetry(t *testing.T) { // 确保 mockConn 实现 io.Closer 接口 var _ io.Closer = (*mockConn)(nil) - From 5942d3bbcbe7c69b649d30ab1f248782c3efd62a Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 18 May 2026 17:43:00 +0800 Subject: [PATCH 11/12] preserve sdk host language state --- common/i18n/i18n.go | 7 ++++ pkg/fscan/scanner.go | 2 + pkg/fscan/scanner_test.go | 78 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+) diff --git a/common/i18n/i18n.go b/common/i18n/i18n.go index 9bc89e3..e1a0437 100644 --- a/common/i18n/i18n.go +++ b/common/i18n/i18n.go @@ -51,6 +51,13 @@ func SetLanguage(l string) { localizer = i18n.NewLocalizer(bundle, lang, FallbackLanguage) } +// GetLanguage returns the currently configured language. +func GetLanguage() string { + mu.RLock() + defer mu.RUnlock() + return lang +} + // GetText 获取国际化文本(无参数) func GetText(key string) string { mu.RLock() diff --git a/pkg/fscan/scanner.go b/pkg/fscan/scanner.go index 9aedaf4..719fbfa 100644 --- a/pkg/fscan/scanner.go +++ b/pkg/fscan/scanner.go @@ -197,7 +197,9 @@ func (s *Scanner) scanOne(ctx context.Context, target Target, sink common.Result fv := buildFlagVars(s.config, target) info := common.HostInfo{Host: strings.TrimSpace(target.Host), URL: strings.TrimSpace(target.URL)} + previousLanguage := i18n.GetLanguage() i18n.SetLanguage(fv.Language) + defer i18n.SetLanguage(previousLanguage) cfg, state, err := common.BuildConfig(fv, &info) if err != nil { diff --git a/pkg/fscan/scanner_test.go b/pkg/fscan/scanner_test.go index 0862255..910d69f 100644 --- a/pkg/fscan/scanner_test.go +++ b/pkg/fscan/scanner_test.go @@ -12,6 +12,7 @@ import ( "github.com/shadow1ng/fscan/common" commonconfig "github.com/shadow1ng/fscan/common/config" + "github.com/shadow1ng/fscan/common/i18n" ) func TestBuildFlagVarsDefaults(t *testing.T) { @@ -220,6 +221,64 @@ func TestScanEachStreamsResults(t *testing.T) { } } +func TestScanUsesConfigTargets(t *testing.T) { + listener := startFTPListener(t) + defer listener.Close() + + port := listener.Addr().(*net.TCPAddr).Port + scanner := NewScanner(Config{ + Targets: []Target{{Host: "127.0.0.1", Ports: []int{port}}}, + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + results, err := scanner.Scan(ctx) + if err != nil { + t.Fatal(err) + } + if !hasPortResult(results, port) { + t.Fatalf("missing configured target port result: %#v", results) + } +} + +func TestScanExplicitTargetsOverrideConfigTargets(t *testing.T) { + configured := startFTPListener(t) + defer configured.Close() + explicit := startFTPListener(t) + defer explicit.Close() + + configuredPort := configured.Addr().(*net.TCPAddr).Port + explicitPort := explicit.Addr().(*net.TCPAddr).Port + scanner := NewScanner(Config{ + Targets: []Target{{Host: "127.0.0.1", Ports: []int{configuredPort}}}, + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + results, err := scanner.Scan(ctx, Target{Host: "127.0.0.1", Ports: []int{explicitPort}}) + if err != nil { + t.Fatal(err) + } + if !hasPortResult(results, explicitPort) { + t.Fatalf("missing explicit target port result: %#v", results) + } + if hasPortResult(results, configuredPort) { + t.Fatalf("configured target should not run when explicit targets are passed: %#v", results) + } +} + func TestScanEachReturnsHandlerError(t *testing.T) { listener := startFTPListener(t) defer listener.Close() @@ -340,10 +399,12 @@ func TestScanDoesNotReplaceGlobalRuntime(t *testing.T) { previousConfig := common.GetGlobalConfig() previousState := common.GetGlobalState() previousFlags := *common.GetFlagVars() + previousLanguage := i18n.GetLanguage() defer func() { common.SetGlobalConfig(previousConfig) common.SetGlobalState(previousState) *common.GetFlagVars() = previousFlags + i18n.SetLanguage(previousLanguage) }() sentinelConfig := common.NewConfig() @@ -351,6 +412,7 @@ func TestScanDoesNotReplaceGlobalRuntime(t *testing.T) { common.SetGlobalConfig(sentinelConfig) common.SetGlobalState(sentinelState) common.GetFlagVars().LogLevel = "sentinel" + i18n.SetLanguage(i18n.LangEN) scanner := NewScanner(Config{ DisablePing: true, @@ -358,6 +420,7 @@ func TestScanDoesNotReplaceGlobalRuntime(t *testing.T) { Timeout: time.Second, Threads: 16, Plugins: []string{"ftp"}, + Language: i18n.LangZH, }) port := listener.Addr().(*net.TCPAddr).Port if _, err := scanner.Scan(context.Background(), Target{Host: "127.0.0.1", Ports: []int{port}}); err != nil { @@ -373,6 +436,9 @@ func TestScanDoesNotReplaceGlobalRuntime(t *testing.T) { if common.GetFlagVars().LogLevel != "sentinel" { t.Fatal("SDK scan replaced global flags") } + if got := i18n.GetLanguage(); got != i18n.LangEN { + t.Fatalf("SDK scan leaked global language = %q, want %q", got, i18n.LangEN) + } } func startFTPListener(t *testing.T) net.Listener { @@ -432,3 +498,15 @@ func hasResult(results []Result, resultType, statusText, plugin string) bool { } return false } + +func hasPortResult(results []Result, port int) bool { + for _, result := range results { + if !result.IsPort() { + continue + } + if got, ok := result.Port(); ok && got == port { + return true + } + } + return false +} From 5a884ca6ade38dc088f5c406b27ac56425c30e81 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 18 May 2026 17:59:00 +0800 Subject: [PATCH 12/12] fix lint issues before merge --- core/port_scan.go | 4 ++-- plugins/local/cleaner.go | 6 +++--- plugins/services/activemq.go | 2 +- plugins/services/cassandra.go | 5 +++-- plugins/services/mssql_raw.go | 1 - plugins/services/oracle_raw.go | 10 +++++++--- plugins/web/webtitle.go | 2 +- 7 files changed, 17 insertions(+), 13 deletions(-) diff --git a/core/port_scan.go b/core/port_scan.go index db81b5d..3fbf7a6 100644 --- a/core/port_scan.go +++ b/core/port_scan.go @@ -421,10 +421,10 @@ func matchFold(a, b string) bool { // 格式: addr service [Product:xxx ||Version:xxx] Banner:(xxx) func buildServiceLogMessage(addr string, serviceInfo *ServiceInfo, isWeb bool) string { var msg strings.Builder - msg.WriteString(fmt.Sprintf("%-21s", addr)) + fmt.Fprintf(&msg, "%-21s", addr) if serviceInfo.Name != "unknown" { - msg.WriteString(fmt.Sprintf(" %-8s", serviceInfo.Name)) + fmt.Fprintf(&msg, " %-8s", serviceInfo.Name) } // 构建 [Product:xxx ||Version:xxx] 格式 diff --git a/plugins/local/cleaner.go b/plugins/local/cleaner.go index 4d4d317..1b7dea1 100644 --- a/plugins/local/cleaner.go +++ b/plugins/local/cleaner.go @@ -58,7 +58,7 @@ func (p *CleanerPlugin) cleanFiles(output *strings.Builder, dir string, names [] for _, name := range names { path := filepath.Join(dir, name) if err := os.Remove(path); err == nil { - output.WriteString(fmt.Sprintf("[清理] %s\n", path)) + fmt.Fprintf(output, "[清理] %s\n", path) cleaned++ } } @@ -70,7 +70,7 @@ func (p *CleanerPlugin) cleanGlob(output *strings.Builder, dir, pattern string) cleaned := 0 for _, f := range matches { if err := os.Remove(f); err == nil { - output.WriteString(fmt.Sprintf("[清理] %s\n", f)) + fmt.Fprintf(output, "[清理] %s\n", f) cleaned++ } } @@ -87,7 +87,7 @@ func (p *CleanerPlugin) cleanUnix(output *strings.Builder) int { } for _, hf := range histFiles { if p.scrubHistory(hf) { - output.WriteString(fmt.Sprintf("[清理] %s 中的 fscan 记录\n", hf)) + fmt.Fprintf(output, "[清理] %s 中的 fscan 记录\n", hf) cleaned++ } } diff --git a/plugins/services/activemq.go b/plugins/services/activemq.go index 02a2b5e..77374ab 100644 --- a/plugins/services/activemq.go +++ b/plugins/services/activemq.go @@ -229,7 +229,7 @@ func (p *ActiveMQPlugin) identifyService(ctx context.Context, info *common.HostI return &ScanResult{ Success: false, Service: "activemq", - Error: fmt.Errorf("Failed to read response: %w", err), + Error: fmt.Errorf("failed to read response: %w", err), } } if n == 0 { diff --git a/plugins/services/cassandra.go b/plugins/services/cassandra.go index 14427a3..dddbed6 100644 --- a/plugins/services/cassandra.go +++ b/plugins/services/cassandra.go @@ -169,9 +169,10 @@ var cqlStreamID int16 func cqlSend(conn net.Conn, opcode byte, body []byte) error { id := cqlStreamID - cqlStreamID++ - if cqlStreamID > 32767 { + if cqlStreamID == 32767 { cqlStreamID = 0 + } else { + cqlStreamID++ } // frame: [1B version|flags] [2B stream] [1B opcode] [4B length] [body] diff --git a/plugins/services/mssql_raw.go b/plugins/services/mssql_raw.go index 9c2e869..9ae00fd 100644 --- a/plugins/services/mssql_raw.go +++ b/plugins/services/mssql_raw.go @@ -323,7 +323,6 @@ func mssqlParseLoginTokens(payload []byte, result *mssqlRawResult) (bool, error) return false, fmt.Errorf("mssql: truncated done token") } status := binary.LittleEndian.Uint16(payload[pos : pos+2]) - pos += 12 return status&(tdsDoneError|tdsDoneSrvError) == 0, nil default: return false, fmt.Errorf("mssql: unexpected login token 0x%02x", token) diff --git a/plugins/services/oracle_raw.go b/plugins/services/oracle_raw.go index 48bb2bd..35d6a59 100644 --- a/plugins/services/oracle_raw.go +++ b/plugins/services/oracle_raw.go @@ -341,8 +341,13 @@ func (s *oracleSession) putString(v string) { func (s *oracleSession) putInt(v interface{}, size uint8, bigEndian, compress bool) { num := toInt64(v) if compress { + neg := num < 0 + encoded := uint64(num) + if neg { + encoded = uint64(-(num + 1)) + 1 + } temp := make([]byte, 8) - binary.BigEndian.PutUint64(temp, uint64(num)) + binary.BigEndian.PutUint64(temp, encoded) temp = bytes.TrimLeft(temp, "\x00") if size > uint8(len(temp)) { size = uint8(len(temp)) @@ -351,8 +356,7 @@ func (s *oracleSession) putInt(v interface{}, size uint8, bigEndian, compress bo s.out.WriteByte(0) return } - if num < 0 { - num = -num + if neg { size |= 0x80 } s.out.WriteByte(size) diff --git a/plugins/web/webtitle.go b/plugins/web/webtitle.go index d9258b6..9c6f69b 100644 --- a/plugins/web/webtitle.go +++ b/plugins/web/webtitle.go @@ -243,7 +243,7 @@ func (p *WebTitlePlugin) formatHeaders(headers http.Header) string { var builder strings.Builder for name, values := range headers { for _, value := range values { - builder.WriteString(fmt.Sprintf("%s: %s\n", name, value)) + fmt.Fprintf(&builder, "%s: %s\n", name, value) } } return builder.String()