allow concurrent embedded scans

This commit is contained in:
ZacharyZcR
2026-05-18 16:11:41 +08:00
parent 3dde0c6a8e
commit 8de7570268
5 changed files with 171 additions and 53 deletions
+40 -11
View File
@@ -14,11 +14,16 @@ import (
) )
var ( var (
globalLogger *logging.Logger globalLogger *logging.Logger
loggerOnce sync.Once loggerOnce sync.Once
loggerMu sync.Mutex
silentLoggerRefs int
) )
func getGlobalLogger() *logging.Logger { func getGlobalLogger() *logging.Logger {
loggerMu.Lock()
defer loggerMu.Unlock()
loggerOnce.Do(func() { loggerOnce.Do(func() {
fv := GetFlagVars() fv := GetFlagVars()
level := getLogLevelFromString(fv.LogLevel) level := getLogLevelFromString(fv.LogLevel)
@@ -27,7 +32,7 @@ func getGlobalLogger() *logging.Logger {
EnableColor: !fv.NoColor, EnableColor: !fv.NoColor,
SlowOutput: false, SlowOutput: false,
ShowProgress: !fv.DisableProgress, ShowProgress: !fv.DisableProgress,
Silent: fv.Silent, Silent: fv.Silent || silentLoggerRefs > 0,
StartTime: GetGlobalState().GetStartTime(), StartTime: GetGlobalState().GetStartTime(),
} }
if fv.Debug { if fv.Debug {
@@ -84,17 +89,41 @@ func LogError(errMsg string) { getGlobalLogger().Error(errMsg) }
// CloseLogger 关闭日志系统,释放文件资源 // CloseLogger 关闭日志系统,释放文件资源
func CloseLogger() { func CloseLogger() {
if globalLogger != nil { loggerMu.Lock()
globalLogger.Close() 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 func resetLoggerLocked() {
// after replacing runtime configuration. closeLoggerLocked()
func ResetLogger() {
if globalLogger != nil {
globalLogger.Close()
}
globalLogger = nil globalLogger = nil
loggerOnce = sync.Once{} loggerOnce = sync.Once{}
} }
func closeLoggerLocked() {
if globalLogger != nil {
globalLogger.Close()
}
}
+1 -1
View File
@@ -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. 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.
+2 -2
View File
@@ -2,8 +2,8 @@
// //
// The package is intentionally thin: it reuses the existing scan core and // The package is intentionally thin: it reuses the existing scan core and
// plugin registry, while hiding CLI flags, stdout output, and result files from // plugin registry, while hiding CLI flags, stdout output, and result files from
// callers. The first SDK surface is serialized internally because the current // callers. Embedded scans build per-session runtime state and can run
// scan core still uses process-wide runtime state. // concurrently.
// //
// Embedded callers can use ValidateConfig before starting a scan, IsSafePlugin // Embedded callers can use ValidateConfig before starting a scan, IsSafePlugin
// or ListPlugins to build plugin allow lists, and ResultType* constants instead // or ListPlugins to build plugin allow lists, and ResultType* constants instead
+5 -39
View File
@@ -21,8 +21,6 @@ import (
_ "github.com/shadow1ng/fscan/plugins/web" _ "github.com/shadow1ng/fscan/plugins/web"
) )
var scanMu sync.Mutex
var defaultSafePlugins = []string{ var defaultSafePlugins = []string{
"activemq", "activemq",
"cassandra", "cassandra",
@@ -149,12 +147,8 @@ func (s *Scanner) ScanEach(ctx context.Context, handle ResultHandler, targets ..
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
defer cancel() defer cancel()
restoreLogger := common.PushSilentLogger()
scanMu.Lock() defer restoreLogger()
defer scanMu.Unlock()
previous := captureRuntime()
defer previous.restore()
var ( var (
errMu sync.Mutex 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 { func (s *Scanner) scanOne(ctx context.Context, target Target, sink common.ResultSink) error {
fv := buildFlagVars(s.config, target) fv := buildFlagVars(s.config, target)
globalFV := common.GetFlagVars()
*globalFV = *fv
info := common.HostInfo{Host: strings.TrimSpace(target.Host), URL: strings.TrimSpace(target.URL)} 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 { if err != nil {
return err return err
} }
@@ -225,12 +217,7 @@ func (s *Scanner) scanOne(ctx context.Context, target Target, sink common.Result
cfg.Output.DisableProgress = true cfg.Output.DisableProgress = true
cfg.Output.ShowProgress = false cfg.Output.ShowProgress = false
common.SetGlobalConfig(cfg) session := common.NewScanSession(cfg, state, fv)
common.SetGlobalState(state)
common.ResetLogger()
common.InitLogger()
session := common.NewScanSession(cfg, state, globalFV)
session.ResultSink = sink session.ResultSink = sink
core.RunScan(ctx, info, session) core.RunScan(ctx, info, session)
return nil return nil
@@ -456,24 +443,3 @@ func getHandlerError(mu *sync.Mutex, err *error) error {
defer mu.Unlock() defer mu.Unlock()
return *err 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()
}
+123
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"net" "net"
"strings" "strings"
"sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "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 { func startFTPListener(t *testing.T) net.Listener {
t.Helper() t.Helper()