diff --git a/common/callback.go b/common/callback.go index 1000a0e..ac84741 100644 --- a/common/callback.go +++ b/common/callback.go @@ -17,23 +17,6 @@ func SetResultCallback(cb ResultCallback) { resultCallback = cb } -// ReplaceResultCallback temporarily replaces the result callback and returns a -// restore function. This is useful for embedded callers that need to collect -// structured results without permanently stealing the callback from another -// subsystem. -func ReplaceResultCallback(cb ResultCallback) func() { - callbackMu.Lock() - previous := resultCallback - resultCallback = cb - callbackMu.Unlock() - - return func() { - callbackMu.Lock() - resultCallback = previous - callbackMu.Unlock() - } -} - // NotifyResult 通知结果给回调函数 func NotifyResult(result interface{}) { callbackMu.RLock() diff --git a/common/session.go b/common/session.go index 464426d..97193cb 100644 --- a/common/session.go +++ b/common/session.go @@ -8,16 +8,21 @@ import ( "sync" "time" - "github.com/shadow1ng/fscan/common/proxy" "github.com/shadow1ng/fscan/common/i18n" + "github.com/shadow1ng/fscan/common/output" + "github.com/shadow1ng/fscan/common/proxy" ) +// ResultSink receives structured scan results for one scan session. +type ResultSink func(result *output.ScanResult) error + // ScanSession 封装单次扫描的全部上下文 // 一次扫描一个 session,并发扫描各自独立 type ScanSession struct { - Config *Config // 不可变,创建后只读 - State *State // 可变,原子操作,每会话独立 - Params *FlagVars // 原始参数,只读 + Config *Config // 不可变,创建后只读 + State *State // 可变,原子操作,每会话独立 + Params *FlagVars // 原始参数,只读 + ResultSink ResultSink // 可选,覆盖全局输出 // 每会话 dialer(懒初始化,取决于代理配置) dialerOnce sync.Once @@ -34,6 +39,15 @@ func NewScanSession(config *Config, state *State, params *FlagVars) *ScanSession } } +// SaveResult saves a scan result through the session sink if present, otherwise +// falls back to the process-wide output pipeline used by the CLI. +func (s *ScanSession) SaveResult(result *output.ScanResult) error { + if s != nil && s.ResultSink != nil { + return s.ResultSink(result) + } + return SaveResult(result) +} + // DialTCP 创建 TCP 连接,内含限速检查、代理、计数 func (s *ScanSession) DialTCP(ctx context.Context, network, address string, timeout time.Duration) (net.Conn, error) { // 检查发包限制 diff --git a/core/base_scan_strategy.go b/core/base_scan_strategy.go index 17ba2e8..0b5c69f 100644 --- a/core/base_scan_strategy.go +++ b/core/base_scan_strategy.go @@ -81,6 +81,11 @@ func (b *BaseScanStrategy) IsPluginApplicableByName(pluginName string, targetHos return false } + // 显式指定插件时,尊重调用方选择,不再强制使用插件默认端口过滤。 + if isCustomMode { + return b.isPluginPassesFilterType(pluginName, isCustomMode, config) + } + // 检查端口匹配和过滤器类型 return b.isPluginApplicableToPortWithHost(pluginName, targetHost, targetPort) && b.isPluginPassesFilterType(pluginName, isCustomMode, config) } diff --git a/core/icmp.go b/core/icmp.go index 80cb4b5..a800fba 100644 --- a/core/icmp.go +++ b/core/icmp.go @@ -55,7 +55,7 @@ func CheckLive(ctx context.Context, hostslist []string, Ping bool, session *comm chanHosts := make(chan string, len(hostslist)) // 处理存活主机 - go handleAliveHosts(chanHosts, hostslist, Ping, &aliveHosts, &aliveHostsMu, existHosts, config, &livewg) + go handleAliveHosts(chanHosts, hostslist, Ping, &aliveHosts, &aliveHostsMu, existHosts, config, session, &livewg) // 根据Ping参数选择检测方式 if Ping { @@ -130,7 +130,7 @@ func IsContain(items []string, item string) bool { return false } -func handleAliveHosts(chanHosts chan string, hostslist []string, isPing bool, aliveHosts *[]string, aliveHostsMu *sync.Mutex, existHosts map[string]struct{}, config *common.Config, livewg *sync.WaitGroup) { +func handleAliveHosts(chanHosts chan string, hostslist []string, isPing bool, aliveHosts *[]string, aliveHostsMu *sync.Mutex, existHosts map[string]struct{}, config *common.Config, session *common.ScanSession, livewg *sync.WaitGroup) { for ip := range chanHosts { if _, ok := existHosts[ip]; !ok && IsContain(hostslist, ip) { existHosts[ip] = struct{}{} @@ -155,7 +155,7 @@ func handleAliveHosts(chanHosts chan string, hostslist []string, isPing bool, al "protocol": protocol, }, } - _ = common.SaveResult(result) + _ = session.SaveResult(result) // 保留原有的控制台输出 if !config.Output.Silent { @@ -771,7 +771,7 @@ func runTcpProbeForHosts(ctx context.Context, hosts []string, session *common.Sc "protocol": "TCP", }, } - _ = common.SaveResult(result) + _ = session.SaveResult(result) if !config.Output.Silent { common.LogInfo(i18n.Tr("host_alive", h, "TCP")) diff --git a/core/port_scan.go b/core/port_scan.go index 9bca839..30f815f 100644 --- a/core/port_scan.go +++ b/core/port_scan.go @@ -487,7 +487,7 @@ func scanSinglePort(ctx context.Context, host string, port int, addr string, ada // 步骤2:记录开放端口 atomic.AddInt64(count, 1) collector.Add(addr) - saveOpenPort(host, port) + saveOpenPort(session, host, port) // 步骤3:服务识别(Scanner负责关闭连接,包括探测中可能创建的新连接) scanner := NewSmartPortInfoScanner(ctx, host, port, conn, timeout, config, session) @@ -640,8 +640,8 @@ func isConnectionClosed(err error) bool { } // saveOpenPort 保存开放端口结果 -func saveOpenPort(host string, port int) { - _ = common.SaveResult(&output.ScanResult{ +func saveOpenPort(session *common.ScanSession, host string, port int) { + _ = session.SaveResult(&output.ScanResult{ Time: time.Now(), Type: output.TypePort, Target: host, @@ -669,7 +669,7 @@ func processServiceResult(host string, port int, addr string, serviceInfo *Servi MarkAsWebService(host, port, serviceInfo) } - _ = common.SaveResult(&output.ScanResult{ + _ = session.SaveResult(&output.ScanResult{ Time: time.Now(), Type: output.TypeService, Target: fmt.Sprintf("%s:%d", host, port), @@ -737,7 +737,7 @@ func tryHTTPFallbackDetection(host string, port int, addr string, config *common "is_web": true, "detected_by": "http_probe", } - _ = common.SaveResult(&output.ScanResult{ + _ = session.SaveResult(&output.ScanResult{ Time: time.Now(), Type: output.TypeService, Target: fmt.Sprintf("%s:%d", host, port), diff --git a/core/scanner.go b/core/scanner.go index 807dd24..5ef4032 100644 --- a/core/scanner.go +++ b/core/scanner.go @@ -281,7 +281,7 @@ func executeScanTask(ctx context.Context, session *common.ScanSession, pluginNam if result != nil { if result.Success { // 保存成功的扫描结果到文件 - savePluginResult(&target, pluginName, result) + savePluginResult(session, &target, pluginName, result) } else if result.Type == plugins.ResultTypeCredential { // 凭据测试完成但未发现弱密码,在error级别输出提示 common.LogError(i18n.Tr("brute_no_weak_pass", target.Host, target.Port, pluginName)) @@ -382,7 +382,7 @@ var defaultSerializer = resultSerializer{ } // savePluginResult 保存插件扫描结果 -func savePluginResult(info *common.HostInfo, pluginName string, result *plugins.Result) { +func savePluginResult(session *common.ScanSession, info *common.HostInfo, pluginName string, result *plugins.Result) { if result == nil || !result.Success || result.Skipped { return } @@ -402,7 +402,7 @@ func savePluginResult(info *common.HostInfo, pluginName string, result *plugins. // 保存结果 target := info.Target() - _ = common.SaveResult(&output.ScanResult{ + _ = session.SaveResult(&output.ScanResult{ Time: time.Now(), Type: serializer.outputType, Target: target, diff --git a/pkg/fscan/README.md b/pkg/fscan/README.md index b1ce772..8cec8b4 100644 --- a/pkg/fscan/README.md +++ b/pkg/fscan/README.md @@ -20,6 +20,9 @@ func main() { DisablePing: true, DisableBrute: true, Plugins: []string{"ssh", "mysql", "redis"}, + OnResult: func(result fscan.Result) { + fmt.Printf("%s %s %s\n", result.Type, result.Target, result.Status) + }, }) results, err := scanner.Scan(context.Background(), fscan.Target{ @@ -30,10 +33,10 @@ func main() { panic(err) } - for _, result := range results { - fmt.Printf("%s %s %s\n", result.Type, result.Target, result.Status) - } + fmt.Printf("total results: %d\n", len(results)) } ``` The SDK currently reuses fscan's existing scan core and plugin registry. Calls are serialized internally because the current core still keeps process-wide runtime state. + +By default, the SDK runs a conservative service-oriented plugin set and blocks plugins with local side effects or active POC behavior. Set `AllowUnsafePlugins` only when the embedding system explicitly wants those capabilities. diff --git a/pkg/fscan/scanner.go b/pkg/fscan/scanner.go index 4692291..c04a5cc 100644 --- a/pkg/fscan/scanner.go +++ b/pkg/fscan/scanner.go @@ -12,6 +12,7 @@ import ( "github.com/shadow1ng/fscan/common" commonconfig "github.com/shadow1ng/fscan/common/config" "github.com/shadow1ng/fscan/common/i18n" + "github.com/shadow1ng/fscan/common/output" "github.com/shadow1ng/fscan/core" "github.com/shadow1ng/fscan/plugins" @@ -22,6 +23,56 @@ import ( var scanMu sync.Mutex +var defaultSafePlugins = []string{ + "activemq", + "cassandra", + "elasticsearch", + "ftp", + "kafka", + "ldap", + "memcached", + "mongodb", + "mssql", + "mysql", + "neo4j", + "netbios", + "oracle", + "postgresql", + "rabbitmq", + "rdp", + "redis", + "rsync", + "smb", + "smtp", + "ssh", + "telnet", + "vnc", + "webtitle", +} + +var unsafePlugins = map[string]struct{}{ + "cleaner": {}, + "crontask": {}, + "download": {}, + "forwardshell": {}, + "keylogger": {}, + "ldpreload": {}, + "minidump": {}, + "reverseshell": {}, + "socks5proxy": {}, + "sshkey": {}, + "systemdservice": {}, + "winbits": {}, + "winifeo": {}, + "winlogon": {}, + "winregistry": {}, + "winschtask": {}, + "winservice": {}, + "winstartup": {}, + "winwmi": {}, + "webpoc": {}, +} + // Scanner runs fscan from another Go process. type Scanner struct { config Config @@ -32,6 +83,12 @@ func NewScanner(config Config) *Scanner { return &Scanner{config: config} } +// DefaultSafePlugins returns the plugin set used by the SDK when Config.Plugins +// is empty. The returned slice can be modified by callers. +func DefaultSafePlugins() []string { + return append([]string(nil), defaultSafePlugins...) +} + // Scan runs the scanner for the provided targets and returns structured // findings. If no targets are provided, Config.Targets is used. func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) { @@ -48,24 +105,30 @@ func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) scanMu.Lock() defer scanMu.Unlock() + previous := captureRuntime() + defer previous.restore() + var ( mu sync.Mutex results []Result ) - restoreCallback := common.ReplaceResultCallback(func(raw interface{}) { - if result, ok := decodeCallbackResult(raw); ok { - mu.Lock() - results = append(results, result) - mu.Unlock() - } - }) - defer restoreCallback() for _, target := range targets { if err := ctx.Err(); err != nil { return snapshotResults(&mu, results), err } - if err := s.scanOne(ctx, target); err != nil { + sink := func(raw *output.ScanResult) error { + if result, ok := convertOutputResult(raw); ok { + mu.Lock() + results = append(results, result) + mu.Unlock() + if s.config.OnResult != nil { + s.config.OnResult(result) + } + } + return nil + } + if err := s.scanOne(ctx, target, sink); err != nil { return snapshotResults(&mu, results), err } } @@ -73,7 +136,7 @@ func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) return snapshotResults(&mu, results), ctx.Err() } -func (s *Scanner) scanOne(ctx context.Context, target Target) error { +func (s *Scanner) scanOne(ctx context.Context, target Target, sink common.ResultSink) error { fv := buildFlagVars(s.config, target) globalFV := common.GetFlagVars() *globalFV = *fv @@ -105,6 +168,7 @@ func (s *Scanner) scanOne(ctx context.Context, target Target) error { common.InitLogger() session := common.NewScanSession(cfg, state, globalFV) + session.ResultSink = sink core.RunScan(ctx, info, session) return nil } @@ -121,6 +185,9 @@ func validateConfig(config Config, targets []Target) error { if !plugins.Exists(name) { return fmt.Errorf("fscan: plugin %q not found", name) } + if !config.AllowUnsafePlugins && !isSafePlugin(name) { + return fmt.Errorf("fscan: plugin %q is not enabled for embedded safe mode", name) + } } for _, target := range targets { if strings.TrimSpace(target.Host) == "" && strings.TrimSpace(target.URL) == "" { @@ -184,7 +251,7 @@ func buildFlagVars(config Config, target Target) *common.FlagVars { return &common.FlagVars{ Host: strings.TrimSpace(target.Host), Ports: formatPorts(ports), - ScanMode: formatPlugins(config.Plugins), + ScanMode: formatPlugins(config), ThreadNum: threadNum, ModuleThreadNum: moduleThreads, TimeoutSec: timeout, @@ -225,12 +292,16 @@ func buildFlagVars(config Config, target Target) *common.FlagVars { } } -func formatPlugins(plugins []string) string { - if len(plugins) == 0 { +func formatPlugins(config Config) string { + pluginNames := config.Plugins + if len(pluginNames) == 0 && !config.AllowUnsafePlugins { + pluginNames = defaultSafePlugins + } + if len(pluginNames) == 0 { return "all" } - parts := make([]string, 0, len(plugins)) - for _, plugin := range plugins { + parts := make([]string, 0, len(pluginNames)) + for _, plugin := range pluginNames { plugin = strings.TrimSpace(plugin) if plugin != "" { parts = append(parts, plugin) @@ -242,6 +313,20 @@ func formatPlugins(plugins []string) string { return strings.Join(parts, ",") } +func isSafePlugin(name string) bool { + name = strings.TrimSpace(name) + if name == "" { + return true + } + if plugins.HasType(name, plugins.PluginTypeLocal) { + return false + } + if _, bad := unsafePlugins[name]; bad { + return false + } + return true +} + func formatPorts(ports []int) string { if len(ports) == 0 { return commonconfig.MainPorts @@ -266,43 +351,43 @@ func secondsOrDefault(value time.Duration, fallback int) int64 { return seconds } -func decodeCallbackResult(raw interface{}) (Result, bool) { - item, ok := raw.(map[string]interface{}) - if !ok { +func convertOutputResult(raw *output.ScanResult) (Result, bool) { + if raw == nil { return Result{}, false } - result := Result{ - Type: stringValue(item["type"]), - Target: stringValue(item["target"]), - Status: stringValue(item["status"]), - Details: mapValue(item["details"]), - } - if t, ok := item["time"].(time.Time); ok { - result.Time = t + Time: raw.Time, + Type: string(raw.Type), + Target: raw.Target, + Status: raw.Status, + Details: raw.Details, } return result, result.Target != "" || result.Status != "" } -func stringValue(value interface{}) string { - if s, ok := value.(string); ok { - return s - } - return "" -} - -func mapValue(value interface{}) map[string]interface{} { - if value == nil { - return nil - } - if m, ok := value.(map[string]interface{}); ok { - return m - } - return nil -} - func snapshotResults(mu *sync.Mutex, results []Result) []Result { mu.Lock() defer mu.Unlock() return append([]Result(nil), results...) } + +type runtimeSnapshot struct { + flagVars common.FlagVars + config *common.Config + state *common.State +} + +func captureRuntime() runtimeSnapshot { + return runtimeSnapshot{ + flagVars: *common.GetFlagVars(), + config: common.GetGlobalConfig(), + state: common.GetGlobalState(), + } +} + +func (s runtimeSnapshot) restore() { + *common.GetFlagVars() = s.flagVars + common.SetGlobalConfig(s.config) + common.SetGlobalState(s.state) + common.ResetLogger() +} diff --git a/pkg/fscan/scanner_test.go b/pkg/fscan/scanner_test.go index c772181..01e4a72 100644 --- a/pkg/fscan/scanner_test.go +++ b/pkg/fscan/scanner_test.go @@ -2,9 +2,13 @@ package fscan import ( "context" + "net" + "strings" + "sync/atomic" "testing" "time" + "github.com/shadow1ng/fscan/common" commonconfig "github.com/shadow1ng/fscan/common/config" ) @@ -17,8 +21,8 @@ func TestBuildFlagVarsDefaults(t *testing.T) { if fv.Ports != commonconfig.MainPorts { t.Fatalf("Ports = %q, want MainPorts", fv.Ports) } - if fv.ScanMode != "all" { - t.Fatalf("ScanMode = %q, want all", fv.ScanMode) + if fv.ScanMode != formatPlugins(Config{Plugins: DefaultSafePlugins()}) { + t.Fatalf("ScanMode = %q, want safe defaults", fv.ScanMode) } if !fv.DisableSave || !fv.Silent || !fv.DisableProgress { t.Fatalf("embedded defaults should disable output side effects") @@ -43,6 +47,12 @@ func TestValidateConfig(t *testing.T) { if err := validateConfig(Config{Plugins: []string{"definitely-missing"}}, []Target{{Host: "127.0.0.1"}}); err == nil { t.Fatal("expected missing plugin error") } + if err := validateConfig(Config{Plugins: []string{"webpoc"}}, []Target{{URL: "http://127.0.0.1"}}); err == nil { + t.Fatal("expected unsafe plugin error") + } + if err := validateConfig(Config{Plugins: []string{"webpoc"}, AllowUnsafePlugins: true}, []Target{{URL: "http://127.0.0.1"}}); err != nil { + t.Fatalf("unsafe plugin with opt-in failed: %v", err) + } if err := validateConfig(Config{}, []Target{{Host: "127.0.0.1", Ports: []int{70000}}}); err == nil { t.Fatal("expected invalid port error") } @@ -64,3 +74,84 @@ func TestScanHonorsCanceledContext(t *testing.T) { t.Fatalf("Scan error = %v, want context.Canceled", err) } } + +func TestScanCollectsResultsThroughSessionSink(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(2 * time.Second)) + _, _ = conn.Write([]byte("220 test FTP\r\n")) + buf := make([]byte, 64) + _, _ = conn.Read(buf) + }(conn) + } + }() + + var callbackCalls int32 + common.SetResultCallback(func(interface{}) { + atomic.AddInt32(&callbackCalls, 1) + }) + defer common.ClearResultCallback() + + var streamed int32 + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + OnResult: func(result Result) { + atomic.AddInt32(&streamed, 1) + }, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + port := listener.Addr().(*net.TCPAddr).Port + results, err := scanner.Scan(ctx, Target{Host: "127.0.0.1", Ports: []int{port}}) + if err != nil { + t.Fatal(err) + } + if len(results) == 0 { + t.Fatal("expected SDK results") + } + if got := atomic.LoadInt32(&streamed); got != int32(len(results)) { + t.Fatalf("streamed length = %d, want %d", got, len(results)) + } + if !hasResult(results, "PORT", "open", "") { + t.Fatalf("missing port result: %#v", results) + } + if !hasResult(results, "SERVICE", "FTP", "ftp") { + t.Fatalf("missing ftp plugin result: %#v", results) + } + if got := atomic.LoadInt32(&callbackCalls); got != 0 { + t.Fatalf("global callback calls = %d, want 0", got) + } +} + +func hasResult(results []Result, resultType, statusText, plugin string) bool { + for _, result := range results { + if result.Type != resultType || !strings.Contains(result.Status, statusText) { + continue + } + if plugin == "" { + return true + } + if result.Details != nil && result.Details["plugin"] == plugin { + return true + } + } + return false +} diff --git a/pkg/fscan/types.go b/pkg/fscan/types.go index 749c5b0..6148b22 100644 --- a/pkg/fscan/types.go +++ b/pkg/fscan/types.go @@ -25,6 +25,11 @@ type Config struct { Plugins []string Ports []int + // AllowUnsafePlugins permits plugins with local side effects or long-lived + // behavior. It is false by default for embedded endpoint use. + AllowUnsafePlugins bool + // OnResult is called for every structured result as it is discovered. + OnResult func(Result) Timeout time.Duration Threads int