diff --git a/common/logger.go b/common/logger.go index a3f018c..23f2d5f 100644 --- a/common/logger.go +++ b/common/logger.go @@ -14,11 +14,16 @@ import ( ) var ( - globalLogger *logging.Logger - loggerOnce sync.Once + globalLogger *logging.Logger + loggerOnce sync.Once + loggerMu sync.Mutex + silentLoggerRefs int ) func getGlobalLogger() *logging.Logger { + loggerMu.Lock() + defer loggerMu.Unlock() + loggerOnce.Do(func() { fv := GetFlagVars() level := getLogLevelFromString(fv.LogLevel) @@ -27,7 +32,7 @@ func getGlobalLogger() *logging.Logger { EnableColor: !fv.NoColor, SlowOutput: false, ShowProgress: !fv.DisableProgress, - Silent: fv.Silent, + Silent: fv.Silent || silentLoggerRefs > 0, StartTime: GetGlobalState().GetStartTime(), } if fv.Debug { @@ -84,17 +89,41 @@ func LogError(errMsg string) { getGlobalLogger().Error(errMsg) } // CloseLogger 关闭日志系统,释放文件资源 func CloseLogger() { - if globalLogger != nil { - globalLogger.Close() + loggerMu.Lock() + defer loggerMu.Unlock() + closeLoggerLocked() +} + +// PushSilentLogger suppresses process-wide legacy log output until the returned +// restore function is called. It is reference counted so concurrent embedded +// scans can overlap safely. +func PushSilentLogger() func() { + loggerMu.Lock() + silentLoggerRefs++ + resetLoggerLocked() + loggerMu.Unlock() + + var once sync.Once + return func() { + once.Do(func() { + loggerMu.Lock() + if silentLoggerRefs > 0 { + silentLoggerRefs-- + } + resetLoggerLocked() + loggerMu.Unlock() + }) } } -// ResetLogger clears the process-wide logger so embedded callers can rebuild it -// after replacing runtime configuration. -func ResetLogger() { - if globalLogger != nil { - globalLogger.Close() - } +func resetLoggerLocked() { + closeLoggerLocked() globalLogger = nil loggerOnce = sync.Once{} } + +func closeLoggerLocked() { + if globalLogger != nil { + globalLogger.Close() + } +} diff --git a/pkg/fscan/README.md b/pkg/fscan/README.md index e476884..63065d7 100644 --- a/pkg/fscan/README.md +++ b/pkg/fscan/README.md @@ -48,7 +48,7 @@ func main() { } ``` -The SDK currently reuses fscan's existing scan core and plugin registry. Calls are serialized internally because the current core still keeps process-wide runtime state. +The SDK currently reuses fscan's existing scan core and plugin registry. Embedded scans build per-session runtime state and can run concurrently. By default, the SDK runs a conservative service-oriented plugin set and blocks plugins with local side effects or active POC behavior. Set `AllowUnsafePlugins` only when the embedding system explicitly wants those capabilities. diff --git a/pkg/fscan/doc.go b/pkg/fscan/doc.go index 98e2b20..75e95cf 100644 --- a/pkg/fscan/doc.go +++ b/pkg/fscan/doc.go @@ -2,8 +2,8 @@ // // The package is intentionally thin: it reuses the existing scan core and // plugin registry, while hiding CLI flags, stdout output, and result files from -// callers. The first SDK surface is serialized internally because the current -// scan core still uses process-wide runtime state. +// callers. Embedded scans build per-session runtime state and can run +// concurrently. // // Embedded callers can use ValidateConfig before starting a scan, IsSafePlugin // or ListPlugins to build plugin allow lists, and ResultType* constants instead diff --git a/pkg/fscan/scanner.go b/pkg/fscan/scanner.go index c3e2ecf..9aedaf4 100644 --- a/pkg/fscan/scanner.go +++ b/pkg/fscan/scanner.go @@ -21,8 +21,6 @@ import ( _ "github.com/shadow1ng/fscan/plugins/web" ) -var scanMu sync.Mutex - var defaultSafePlugins = []string{ "activemq", "cassandra", @@ -149,12 +147,8 @@ func (s *Scanner) ScanEach(ctx context.Context, handle ResultHandler, targets .. ctx, cancel := context.WithCancel(ctx) defer cancel() - - scanMu.Lock() - defer scanMu.Unlock() - - previous := captureRuntime() - defer previous.restore() + restoreLogger := common.PushSilentLogger() + defer restoreLogger() var ( errMu sync.Mutex @@ -201,13 +195,11 @@ func (s *Scanner) ScanEach(ctx context.Context, handle ResultHandler, targets .. func (s *Scanner) scanOne(ctx context.Context, target Target, sink common.ResultSink) error { fv := buildFlagVars(s.config, target) - globalFV := common.GetFlagVars() - *globalFV = *fv info := common.HostInfo{Host: strings.TrimSpace(target.Host), URL: strings.TrimSpace(target.URL)} - i18n.SetLanguage(globalFV.Language) + i18n.SetLanguage(fv.Language) - cfg, state, err := common.BuildConfig(globalFV, &info) + cfg, state, err := common.BuildConfig(fv, &info) if err != nil { return err } @@ -225,12 +217,7 @@ func (s *Scanner) scanOne(ctx context.Context, target Target, sink common.Result cfg.Output.DisableProgress = true cfg.Output.ShowProgress = false - common.SetGlobalConfig(cfg) - common.SetGlobalState(state) - common.ResetLogger() - common.InitLogger() - - session := common.NewScanSession(cfg, state, globalFV) + session := common.NewScanSession(cfg, state, fv) session.ResultSink = sink core.RunScan(ctx, info, session) return nil @@ -456,24 +443,3 @@ func getHandlerError(mu *sync.Mutex, err *error) error { defer mu.Unlock() return *err } - -type runtimeSnapshot struct { - flagVars common.FlagVars - config *common.Config - state *common.State -} - -func captureRuntime() runtimeSnapshot { - return runtimeSnapshot{ - flagVars: *common.GetFlagVars(), - config: common.GetGlobalConfig(), - state: common.GetGlobalState(), - } -} - -func (s runtimeSnapshot) restore() { - *common.GetFlagVars() = s.flagVars - common.SetGlobalConfig(s.config) - common.SetGlobalState(s.state) - common.ResetLogger() -} diff --git a/pkg/fscan/scanner_test.go b/pkg/fscan/scanner_test.go index 09b7e09..0862255 100644 --- a/pkg/fscan/scanner_test.go +++ b/pkg/fscan/scanner_test.go @@ -5,6 +5,7 @@ import ( "errors" "net" "strings" + "sync" "sync/atomic" "testing" "time" @@ -252,6 +253,128 @@ func TestScanEachRequiresHandler(t *testing.T) { } } +func TestScanEachRunsConcurrent(t *testing.T) { + first := startFTPListener(t) + defer first.Close() + second := startFTPListener(t) + defer second.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + blocked := make(chan struct{}) + release := make(chan struct{}) + firstErr := make(chan error, 1) + var blockOnce sync.Once + + go func() { + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + port := first.Addr().(*net.TCPAddr).Port + firstErr <- scanner.ScanEach(ctx, func(result Result) error { + if result.IsPort() { + blockOnce.Do(func() { close(blocked) }) + select { + case <-release: + case <-ctx.Done(): + return ctx.Err() + } + } + return nil + }, Target{Host: "127.0.0.1", Ports: []int{port}}) + }() + + select { + case <-blocked: + case <-time.After(2 * time.Second): + t.Fatal("first scan did not reach handler") + } + + secondErr := make(chan error, 1) + var secondResults []Result + go func() { + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + port := second.Addr().(*net.TCPAddr).Port + secondErr <- scanner.ScanEach(ctx, func(result Result) error { + secondResults = append(secondResults, result) + return nil + }, Target{Host: "127.0.0.1", Ports: []int{port}}) + }() + + select { + case err := <-secondErr: + if err != nil { + close(release) + t.Fatalf("second scan failed: %v", err) + } + case <-time.After(2 * time.Second): + close(release) + t.Fatal("second scan blocked behind first scan") + } + if !hasResult(secondResults, ResultTypePort, "open", "") { + close(release) + t.Fatalf("missing second scan result: %#v", secondResults) + } + + close(release) + if err := <-firstErr; err != nil { + t.Fatalf("first scan failed: %v", err) + } +} + +func TestScanDoesNotReplaceGlobalRuntime(t *testing.T) { + listener := startFTPListener(t) + defer listener.Close() + + previousConfig := common.GetGlobalConfig() + previousState := common.GetGlobalState() + previousFlags := *common.GetFlagVars() + defer func() { + common.SetGlobalConfig(previousConfig) + common.SetGlobalState(previousState) + *common.GetFlagVars() = previousFlags + }() + + sentinelConfig := common.NewConfig() + sentinelState := common.NewState() + common.SetGlobalConfig(sentinelConfig) + common.SetGlobalState(sentinelState) + common.GetFlagVars().LogLevel = "sentinel" + + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Threads: 16, + Plugins: []string{"ftp"}, + }) + port := listener.Addr().(*net.TCPAddr).Port + if _, err := scanner.Scan(context.Background(), Target{Host: "127.0.0.1", Ports: []int{port}}); err != nil { + t.Fatal(err) + } + + if common.GetGlobalConfig() != sentinelConfig { + t.Fatal("SDK scan replaced global config") + } + if common.GetGlobalState() != sentinelState { + t.Fatal("SDK scan replaced global state") + } + if common.GetFlagVars().LogLevel != "sentinel" { + t.Fatal("SDK scan replaced global flags") + } +} + func startFTPListener(t *testing.T) net.Listener { t.Helper()