From 6bfa05cb452a6f8ac4351c89dbbeb2a17b8507bc Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 18 May 2026 14:41:15 +0800 Subject: [PATCH] add embeddable scanner SDK --- .gitignore | 4 +- common/callback.go | 17 +++ common/logger.go | 10 ++ pkg/fscan/README.md | 39 +++++ pkg/fscan/doc.go | 7 + pkg/fscan/scanner.go | 308 ++++++++++++++++++++++++++++++++++++++ pkg/fscan/scanner_test.go | 66 ++++++++ pkg/fscan/types.go | 66 ++++++++ 8 files changed, 515 insertions(+), 2 deletions(-) create mode 100644 pkg/fscan/README.md create mode 100644 pkg/fscan/doc.go create mode 100644 pkg/fscan/scanner.go create mode 100644 pkg/fscan/scanner_test.go create mode 100644 pkg/fscan/types.go diff --git a/.gitignore b/.gitignore index ed5ad95..66c0f15 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,9 @@ result.txt result.json -main +/main .idea fscan.exe -fscan +/fscan fscanapi.csv # IDE files / IDE 文件 diff --git a/common/callback.go b/common/callback.go index ac84741..1000a0e 100644 --- a/common/callback.go +++ b/common/callback.go @@ -17,6 +17,23 @@ func SetResultCallback(cb ResultCallback) { resultCallback = cb } +// ReplaceResultCallback temporarily replaces the result callback and returns a +// restore function. This is useful for embedded callers that need to collect +// structured results without permanently stealing the callback from another +// subsystem. +func ReplaceResultCallback(cb ResultCallback) func() { + callbackMu.Lock() + previous := resultCallback + resultCallback = cb + callbackMu.Unlock() + + return func() { + callbackMu.Lock() + resultCallback = previous + callbackMu.Unlock() + } +} + // NotifyResult 通知结果给回调函数 func NotifyResult(result interface{}) { callbackMu.RLock() diff --git a/common/logger.go b/common/logger.go index 323a013..a3f018c 100644 --- a/common/logger.go +++ b/common/logger.go @@ -88,3 +88,13 @@ func CloseLogger() { globalLogger.Close() } } + +// ResetLogger clears the process-wide logger so embedded callers can rebuild it +// after replacing runtime configuration. +func ResetLogger() { + if globalLogger != nil { + globalLogger.Close() + } + globalLogger = nil + loggerOnce = sync.Once{} +} diff --git a/pkg/fscan/README.md b/pkg/fscan/README.md new file mode 100644 index 0000000..b1ce772 --- /dev/null +++ b/pkg/fscan/README.md @@ -0,0 +1,39 @@ +# fscan SDK + +`pkg/fscan` exposes fscan as an embeddable Go scanner while keeping the CLI unchanged. + +```go +package main + +import ( + "context" + "fmt" + "time" + + fscan "github.com/shadow1ng/fscan/pkg/fscan" +) + +func main() { + scanner := fscan.NewScanner(fscan.Config{ + Timeout: 3 * time.Second, + Threads: 128, + DisablePing: true, + DisableBrute: true, + Plugins: []string{"ssh", "mysql", "redis"}, + }) + + results, err := scanner.Scan(context.Background(), fscan.Target{ + Host: "192.168.1.10", + Ports: []int{22, 3306, 6379}, + }) + if err != nil { + panic(err) + } + + for _, result := range results { + fmt.Printf("%s %s %s\n", result.Type, result.Target, result.Status) + } +} +``` + +The SDK currently reuses fscan's existing scan core and plugin registry. Calls are serialized internally because the current core still keeps process-wide runtime state. diff --git a/pkg/fscan/doc.go b/pkg/fscan/doc.go new file mode 100644 index 0000000..ea441e6 --- /dev/null +++ b/pkg/fscan/doc.go @@ -0,0 +1,7 @@ +// Package fscan exposes fscan as an embeddable scanner library. +// +// The package is intentionally thin: it reuses the existing scan core and +// plugin registry, while hiding CLI flags, stdout output, and result files from +// callers. The first SDK surface is serialized internally because the current +// scan core still uses process-wide runtime state. +package fscan diff --git a/pkg/fscan/scanner.go b/pkg/fscan/scanner.go new file mode 100644 index 0000000..4692291 --- /dev/null +++ b/pkg/fscan/scanner.go @@ -0,0 +1,308 @@ +package fscan + +import ( + "context" + "fmt" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/shadow1ng/fscan/common" + commonconfig "github.com/shadow1ng/fscan/common/config" + "github.com/shadow1ng/fscan/common/i18n" + "github.com/shadow1ng/fscan/core" + "github.com/shadow1ng/fscan/plugins" + + _ "github.com/shadow1ng/fscan/plugins/local" + _ "github.com/shadow1ng/fscan/plugins/services" + _ "github.com/shadow1ng/fscan/plugins/web" +) + +var scanMu sync.Mutex + +// Scanner runs fscan from another Go process. +type Scanner struct { + config Config +} + +// NewScanner creates an embedded scanner. +func NewScanner(config Config) *Scanner { + return &Scanner{config: config} +} + +// Scan runs the scanner for the provided targets and returns structured +// findings. If no targets are provided, Config.Targets is used. +func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) { + if ctx == nil { + ctx = context.Background() + } + if len(targets) == 0 { + targets = s.config.Targets + } + if err := validateConfig(s.config, targets); err != nil { + return nil, err + } + + scanMu.Lock() + defer scanMu.Unlock() + + var ( + mu sync.Mutex + results []Result + ) + restoreCallback := common.ReplaceResultCallback(func(raw interface{}) { + if result, ok := decodeCallbackResult(raw); ok { + mu.Lock() + results = append(results, result) + mu.Unlock() + } + }) + defer restoreCallback() + + for _, target := range targets { + if err := ctx.Err(); err != nil { + return snapshotResults(&mu, results), err + } + if err := s.scanOne(ctx, target); err != nil { + return snapshotResults(&mu, results), err + } + } + + return snapshotResults(&mu, results), ctx.Err() +} + +func (s *Scanner) scanOne(ctx context.Context, target Target) error { + fv := buildFlagVars(s.config, target) + globalFV := common.GetFlagVars() + *globalFV = *fv + info := common.HostInfo{Host: strings.TrimSpace(target.Host), URL: strings.TrimSpace(target.URL)} + + i18n.SetLanguage(globalFV.Language) + + cfg, state, err := common.BuildConfig(globalFV, &info) + if err != nil { + return err + } + if len(s.config.UserPassPairs) > 0 { + cfg.Credentials.UserPassPairs = make([]commonconfig.CredentialPair, 0, len(s.config.UserPassPairs)) + for _, pair := range s.config.UserPassPairs { + cfg.Credentials.UserPassPairs = append(cfg.Credentials.UserPassPairs, commonconfig.CredentialPair{ + Username: pair.Username, + Password: pair.Password, + }) + } + } + cfg.Output.DisableSave = true + cfg.Output.Silent = true + cfg.Output.DisableProgress = true + cfg.Output.ShowProgress = false + + common.SetGlobalConfig(cfg) + common.SetGlobalState(state) + common.ResetLogger() + common.InitLogger() + + session := common.NewScanSession(cfg, state, globalFV) + core.RunScan(ctx, info, session) + return nil +} + +func validateConfig(config Config, targets []Target) error { + if len(targets) == 0 { + return fmt.Errorf("fscan: at least one target is required") + } + for _, plugin := range config.Plugins { + name := strings.TrimSpace(plugin) + if name == "" { + continue + } + if !plugins.Exists(name) { + return fmt.Errorf("fscan: plugin %q not found", name) + } + } + for _, target := range targets { + if strings.TrimSpace(target.Host) == "" && strings.TrimSpace(target.URL) == "" { + return fmt.Errorf("fscan: target host or URL is required") + } + if strings.TrimSpace(target.Host) != "" && strings.TrimSpace(target.URL) != "" { + return fmt.Errorf("fscan: target cannot set both Host and URL") + } + for _, port := range target.Ports { + if port < 1 || port > 65535 { + return fmt.Errorf("fscan: invalid port %d", port) + } + } + } + for _, port := range config.Ports { + if port < 1 || port > 65535 { + return fmt.Errorf("fscan: invalid port %d", port) + } + } + return nil +} + +func buildFlagVars(config Config, target Target) *common.FlagVars { + timeout := secondsOrDefault(config.Timeout, common.DefaultTimeout) + webTimeout := secondsOrDefault(config.WebTimeout, 5) + + threadNum := config.Threads + if threadNum <= 0 { + threadNum = common.DefaultThreadNum + } + moduleThreads := config.ModuleThreads + if moduleThreads <= 0 { + moduleThreads = 20 + } + maxRetries := config.MaxRetries + if maxRetries <= 0 { + maxRetries = 3 + } + maxRedirects := config.MaxRedirects + if maxRedirects <= 0 { + maxRedirects = 10 + } + pocConcurrency := config.POCConcurrency + if pocConcurrency <= 0 { + pocConcurrency = 20 + } + icmpRate := config.ICMPRate + if icmpRate <= 0 { + icmpRate = 0.1 + } + language := config.Language + if language == "" { + language = common.DefaultLanguage + } + + ports := config.Ports + if len(target.Ports) > 0 { + ports = target.Ports + } + + return &common.FlagVars{ + Host: strings.TrimSpace(target.Host), + Ports: formatPorts(ports), + ScanMode: formatPlugins(config.Plugins), + ThreadNum: threadNum, + ModuleThreadNum: moduleThreads, + TimeoutSec: timeout, + GlobalTimeout: 180, + DisablePing: config.DisablePing, + DisableTcpProbe: config.DisableTCPProbe, + AliveOnly: false, + DisableBrute: config.DisableBrute, + MaxRetries: maxRetries, + Username: strings.Join(config.Usernames, ","), + Password: strings.Join(config.Passwords, ","), + Domain: config.Domain, + SSHKeyPath: config.SSHKeyPath, + TargetURL: strings.TrimSpace(target.URL), + WebTimeout: webTimeout, + MaxRedirects: maxRedirects, + HTTPProxy: config.HTTPProxy, + Socks5Proxy: config.Socks5Proxy, + Iface: config.Interface, + PocPath: config.POCPath, + PocName: config.POCName, + PocFull: config.POCFull, + PocNum: pocConcurrency, + DisablePocScan: config.DisablePOCScan, + PacketRateLimit: config.PacketRateLimit, + MaxPacketCount: config.MaxPacketCount, + ICMPRate: icmpRate, + Outputfile: "result.txt", + OutputFormat: "txt", + DisableSave: true, + Silent: true, + NoColor: true, + LogLevel: common.LogLevelError, + DisableProgress: true, + Language: language, + ForwardShellPort: 4444, + KeyloggerOutputFile: "keylog.txt", + } +} + +func formatPlugins(plugins []string) string { + if len(plugins) == 0 { + return "all" + } + parts := make([]string, 0, len(plugins)) + for _, plugin := range plugins { + plugin = strings.TrimSpace(plugin) + if plugin != "" { + parts = append(parts, plugin) + } + } + if len(parts) == 0 { + return "all" + } + return strings.Join(parts, ",") +} + +func formatPorts(ports []int) string { + if len(ports) == 0 { + return commonconfig.MainPorts + } + ports = append([]int(nil), ports...) + sort.Ints(ports) + parts := make([]string, 0, len(ports)) + for _, port := range ports { + parts = append(parts, strconv.Itoa(port)) + } + return strings.Join(parts, ",") +} + +func secondsOrDefault(value time.Duration, fallback int) int64 { + if value <= 0 { + return int64(fallback) + } + seconds := int64(value.Round(time.Second) / time.Second) + if seconds < 1 { + return 1 + } + return seconds +} + +func decodeCallbackResult(raw interface{}) (Result, bool) { + item, ok := raw.(map[string]interface{}) + if !ok { + return Result{}, false + } + + result := Result{ + Type: stringValue(item["type"]), + Target: stringValue(item["target"]), + Status: stringValue(item["status"]), + Details: mapValue(item["details"]), + } + if t, ok := item["time"].(time.Time); ok { + result.Time = t + } + return result, result.Target != "" || result.Status != "" +} + +func stringValue(value interface{}) string { + if s, ok := value.(string); ok { + return s + } + return "" +} + +func mapValue(value interface{}) map[string]interface{} { + if value == nil { + return nil + } + if m, ok := value.(map[string]interface{}); ok { + return m + } + return nil +} + +func snapshotResults(mu *sync.Mutex, results []Result) []Result { + mu.Lock() + defer mu.Unlock() + return append([]Result(nil), results...) +} diff --git a/pkg/fscan/scanner_test.go b/pkg/fscan/scanner_test.go new file mode 100644 index 0000000..c772181 --- /dev/null +++ b/pkg/fscan/scanner_test.go @@ -0,0 +1,66 @@ +package fscan + +import ( + "context" + "testing" + "time" + + commonconfig "github.com/shadow1ng/fscan/common/config" +) + +func TestBuildFlagVarsDefaults(t *testing.T) { + fv := buildFlagVars(Config{}, Target{Host: "127.0.0.1"}) + + if fv.Host != "127.0.0.1" { + t.Fatalf("Host = %q", fv.Host) + } + if fv.Ports != commonconfig.MainPorts { + t.Fatalf("Ports = %q, want MainPorts", fv.Ports) + } + if fv.ScanMode != "all" { + t.Fatalf("ScanMode = %q, want all", fv.ScanMode) + } + if !fv.DisableSave || !fv.Silent || !fv.DisableProgress { + t.Fatalf("embedded defaults should disable output side effects") + } +} + +func TestBuildFlagVarsTargetPortsOverride(t *testing.T) { + fv := buildFlagVars(Config{Ports: []int{22, 80}}, Target{Host: "127.0.0.1", Ports: []int{3306, 22}}) + + if fv.Ports != "22,3306" { + t.Fatalf("Ports = %q, want sorted target override", fv.Ports) + } +} + +func TestValidateConfig(t *testing.T) { + if err := validateConfig(Config{}, nil); err == nil { + t.Fatal("expected missing target error") + } + if err := validateConfig(Config{}, []Target{{Host: "127.0.0.1", URL: "http://127.0.0.1"}}); err == nil { + t.Fatal("expected host/url conflict") + } + if err := validateConfig(Config{Plugins: []string{"definitely-missing"}}, []Target{{Host: "127.0.0.1"}}); err == nil { + t.Fatal("expected missing plugin error") + } + if err := validateConfig(Config{}, []Target{{Host: "127.0.0.1", Ports: []int{70000}}}); err == nil { + t.Fatal("expected invalid port error") + } +} + +func TestScanHonorsCanceledContext(t *testing.T) { + scanner := NewScanner(Config{ + DisablePing: true, + DisableBrute: true, + Timeout: time.Second, + Plugins: []string{"redis"}, + }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := scanner.Scan(ctx, Target{Host: "127.0.0.1", Ports: []int{6379}}) + if err != context.Canceled { + t.Fatalf("Scan error = %v, want context.Canceled", err) + } +} diff --git a/pkg/fscan/types.go b/pkg/fscan/types.go new file mode 100644 index 0000000..749c5b0 --- /dev/null +++ b/pkg/fscan/types.go @@ -0,0 +1,66 @@ +package fscan + +import ( + "time" +) + +// Target describes one scan target. Use Host for host/IP/CIDR/range service +// scans, or URL for web scans. Ports applies only to Host scans. +type Target struct { + Host string + URL string + Ports []int +} + +// CredentialPair pins one username/password pair. +type CredentialPair struct { + Username string + Password string +} + +// Config controls an embedded scan. Zero values use the same conservative +// defaults as the CLI, except output is silent and file saving is disabled. +type Config struct { + Targets []Target + + Plugins []string + Ports []int + + Timeout time.Duration + Threads int + ModuleThreads int + MaxRetries int + + DisablePing bool + DisableTCPProbe bool + DisableBrute bool + + Usernames []string + Passwords []string + UserPassPairs []CredentialPair + Domain string + SSHKeyPath string + HTTPProxy string + Socks5Proxy string + Interface string + WebTimeout time.Duration + MaxRedirects int + DisablePOCScan bool + POCPath string + POCName string + POCFull bool + POCConcurrency int + PacketRateLimit int64 + MaxPacketCount int64 + ICMPRate float64 + Language string +} + +// Result is the structured scan result returned to embedded callers. +type Result struct { + Time time.Time `json:"time"` + Type string `json:"type"` + Target string `json:"target"` + Status string `json:"status"` + Details map[string]interface{} `json:"details,omitempty"` +}