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/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/common/logger.go b/common/logger.go index 323a013..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,6 +89,40 @@ func LogError(errMsg string) { getGlobalLogger().Error(errMsg) } // CloseLogger 关闭日志系统,释放文件资源 func CloseLogger() { + 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() + }) + } +} + +func resetLoggerLocked() { + closeLoggerLocked() + globalLogger = nil + loggerOnce = sync.Once{} +} + +func closeLoggerLocked() { if globalLogger != nil { globalLogger.Close() } diff --git a/common/session.go b/common/session.go index 464426d..d7e81d2 100644 --- a/common/session.go +++ b/common/session.go @@ -4,25 +4,31 @@ import ( "context" "fmt" "net" + "net/http" "strings" "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 - 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 创建会话 @@ -34,18 +40,66 @@ 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) +} + +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() + dialer, err := s.getDialer(timeout) if err != nil { - LogError(fmt.Sprintf("获取代理拨号器失败: %v", err)) + s.LogError(fmt.Sprintf("获取代理拨号器失败: %v", err)) s.State.IncrementTCPFailedPacketCount() return nil, err } @@ -53,7 +107,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 } @@ -66,18 +120,70 @@ 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 +// 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 } -func (s *ScanSession) createProxyConfig() *proxy.ProxyConfig { +// 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 + } + + 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(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 new file mode 100644 index 0000000..cb03d17 --- /dev/null +++ b/common/session_test.go @@ -0,0 +1,148 @@ +package common + +import ( + "io" + "net/http" + "strings" + "testing" + "time" +) + +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") + } +} + +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) + } +} + +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/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 17ba2e8..1861e46 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) } @@ -165,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 @@ -184,6 +189,7 @@ func (b *BaseScanStrategy) LogPluginInfo(config *common.Config) { _ = allPlugins _ = isCustomMode _ = prefix + _ = session } // formatPluginList 格式化插件列表(超过5个时精简显示) @@ -200,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/icmp.go b/core/icmp.go index 80cb4b5..2ac1966 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 { @@ -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 @@ -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,12 +155,9 @@ func handleAliveHosts(chanHosts chan string, hostslist []string, isPing bool, al "protocol": protocol, }, } - _ = common.SaveResult(result) + _ = 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 } @@ -771,11 +767,9 @@ 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")) - } + session.LogInfo(i18n.Tr("host_alive", h, "TCP")) } }(host) } 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/port_scan.go b/core/port_scan.go index 9bca839..3fbf7a6 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) @@ -176,26 +176,26 @@ func EnhancedPortScan(ctx context.Context, hosts []string, ports string, timeout } // 检查代理可靠性,如果存在全回显问题则警告 - if common.IsProxyEnabled() && !common.IsProxyReliable() { - common.LogError("检测到代理存在全回显问题,端口扫描结果可能不准确") + if session.ProxyEnabled() && !session.ProxyReliable() { + 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 @@ -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] 格式 @@ -465,16 +465,16 @@ 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 { - common.LogDebug(fmt.Sprintf("代理验证失败 %s: %s", addr, verifyMethod)) + session.LogDebug(fmt.Sprintf("代理验证失败 %s: %s", addr, verifyMethod)) _ = conn.Close() return } // 步骤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) @@ -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) @@ -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" } @@ -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, @@ -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 } @@ -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), @@ -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 @@ -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), @@ -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 807dd24..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)) } // 更新统计和进度(任务真正完成时才更新) @@ -281,13 +284,13 @@ 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)) + 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)) } } } @@ -382,7 +385,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 +405,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/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..dd3c3ab 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,15 +133,15 @@ 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") { + 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 } @@ -186,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") { @@ -204,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 } @@ -330,19 +306,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 +326,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 +343,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 +354,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 +366,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() +} 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 new file mode 100644 index 0000000..63065d7 --- /dev/null +++ b/pkg/fscan/README.md @@ -0,0 +1,66 @@ +# fscan SDK + +`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 + +import ( + "context" + "fmt" + "time" + + fscan "github.com/shadow1ng/fscan/pkg/fscan" +) + +func main() { + config := fscan.Config{ + Timeout: 3 * time.Second, + Threads: 128, + DisablePing: true, + DisableBrute: true, + Plugins: []string{"ssh", "mysql", "redis"}, + OnResult: func(result fscan.Result) { + 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) + } + + 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}, + }) + if err != nil { + panic(err) + } + + fmt.Println("scan finished") +} +``` + +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. + +## 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 new file mode 100644 index 0000000..75e95cf --- /dev/null +++ b/pkg/fscan/doc.go @@ -0,0 +1,15 @@ +// 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. 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 +// 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 new file mode 100644 index 0000000..719fbfa --- /dev/null +++ b/pkg/fscan/scanner.go @@ -0,0 +1,447 @@ +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/common/output" + "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 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", +} + +// 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} +} + +// 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...) +} + +// 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 + } + return plugins.IsSafe(name) +} + +// 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() + } + if len(targets) == 0 { + targets = s.config.Targets + } + if err := validateConfig(s.config, targets); err != nil { + return err + } + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + restoreLogger := common.PushSilentLogger() + defer restoreLogger() + + var ( + errMu sync.Mutex + handleMu sync.Mutex + handlerErr error + ) + + for _, target := range targets { + if err := ctx.Err(); err != nil { + if stored := getHandlerError(&errMu, &handlerErr); stored != nil { + return stored + } + return err + } + sink := func(raw *output.ScanResult) error { + if result, ok := convertOutputResult(raw); ok { + 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 err + } + if stored := getHandlerError(&errMu, &handlerErr); stored != nil { + return stored + } + } + + 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 { + 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 { + 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 + + session := common.NewScanSession(cfg, state, fv) + session.ResultSink = sink + 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 _, name := range normalizePlugins(config.Plugins) { + 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) == "" { + 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), + 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(config Config) string { + parts := normalizePlugins(config.Plugins) + if len(parts) == 0 { + if config.AllowUnsafePlugins { + return "all" + } + parts = defaultSafePlugins + } + 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 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) + } + seconds := int64(value.Round(time.Second) / time.Second) + if seconds < 1 { + return 1 + } + return seconds +} + +func convertOutputResult(raw *output.ScanResult) (Result, bool) { + if raw == nil { + return Result{}, false + } + result := Result{ + Time: raw.Time, + Type: string(raw.Type), + Target: raw.Target, + Status: raw.Status, + Details: raw.Details, + } + return result, result.Target != "" || result.Status != "" +} + +func snapshotResults(mu *sync.Mutex, results []Result) []Result { + mu.Lock() + defer mu.Unlock() + 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 +} diff --git a/pkg/fscan/scanner_test.go b/pkg/fscan/scanner_test.go new file mode 100644 index 0000000..910d69f --- /dev/null +++ b/pkg/fscan/scanner_test.go @@ -0,0 +1,512 @@ +package fscan + +import ( + "context" + "errors" + "net" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/shadow1ng/fscan/common" + commonconfig "github.com/shadow1ng/fscan/common/config" + "github.com/shadow1ng/fscan/common/i18n" +) + +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 != 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") + } +} + +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}}) + + 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{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{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") + } + 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) { + 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) + } +} + +func TestScanCollectsResultsThroughSessionSink(t *testing.T) { + listener := startFTPListener(t) + defer listener.Close() + + 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, ResultTypePort, "open", "") { + t.Fatalf("missing port result: %#v", results) + } + if !hasResult(results, ResultTypeService, "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 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 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() + + 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 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() + previousLanguage := i18n.GetLanguage() + defer func() { + common.SetGlobalConfig(previousConfig) + common.SetGlobalState(previousState) + *common.GetFlagVars() = previousFlags + i18n.SetLanguage(previousLanguage) + }() + + sentinelConfig := common.NewConfig() + sentinelState := common.NewState() + common.SetGlobalConfig(sentinelConfig) + common.SetGlobalState(sentinelState) + common.GetFlagVars().LogLevel = "sentinel" + i18n.SetLanguage(i18n.LangEN) + + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + 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 { + 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") + } + 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 { + 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) { + continue + } + if plugin == "" { + return true + } + if result.Details != nil && result.Details["plugin"] == plugin { + return true + } + } + 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 +} diff --git a/pkg/fscan/types.go b/pkg/fscan/types.go new file mode 100644 index 0000000..1794eba --- /dev/null +++ b/pkg/fscan/types.go @@ -0,0 +1,116 @@ +package fscan + +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 { + Host string + URL string + Ports []int +} + +// CredentialPair pins one username/password pair. +type CredentialPair struct { + Username string + 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 { + Targets []Target + + 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 + 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"` +} 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/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/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) - 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, 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/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() }) } 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()