From d4ed0867c9852c7a8611ec7ca0f65d39a08316b4 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Mon, 18 May 2026 15:49:57 +0800 Subject: [PATCH] polish scanner SDK API and docs --- examples/embed-basic/main.go | 40 +++++++ examples/embed-stream/main.go | 49 ++++++++ pkg/fscan/README.md | 34 +++++- pkg/fscan/doc.go | 8 ++ pkg/fscan/result.go | 218 ++++++++++++++++++++++++++++++++++ pkg/fscan/result_test.go | 122 +++++++++++++++++++ pkg/fscan/scanner.go | 203 ++++++++++++++++++++++++------- pkg/fscan/scanner_test.go | 188 +++++++++++++++++++++++++---- pkg/fscan/types.go | 45 +++++++ 9 files changed, 836 insertions(+), 71 deletions(-) create mode 100644 examples/embed-basic/main.go create mode 100644 examples/embed-stream/main.go create mode 100644 pkg/fscan/result.go create mode 100644 pkg/fscan/result_test.go 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 index 8cec8b4..e476884 100644 --- a/pkg/fscan/README.md +++ b/pkg/fscan/README.md @@ -2,6 +2,8 @@ `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 @@ -14,18 +16,27 @@ import ( ) func main() { - scanner := fscan.NewScanner(fscan.Config{ + config := fscan.Config{ Timeout: 3 * time.Second, Threads: 128, 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) + 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) + } - results, err := scanner.Scan(context.Background(), fscan.Target{ + 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}, }) @@ -33,10 +44,23 @@ func main() { panic(err) } - fmt.Printf("total results: %d\n", len(results)) + fmt.Println("scan finished") } ``` 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. + +## 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 index ea441e6..98e2b20 100644 --- a/pkg/fscan/doc.go +++ b/pkg/fscan/doc.go @@ -4,4 +4,12 @@ // 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. +// +// 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 index c04a5cc..b9f2bb4 100644 --- a/pkg/fscan/scanner.go +++ b/pkg/fscan/scanner.go @@ -89,9 +89,83 @@ 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 + } + if plugins.HasType(name, plugins.PluginTypeLocal) { + return false + } + if _, bad := unsafePlugins[name]; bad { + return false + } + return true +} + // 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() } @@ -99,9 +173,12 @@ func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) targets = s.config.Targets } if err := validateConfig(s.config, targets); err != nil { - return nil, err + return err } + ctx, cancel := context.WithCancel(ctx) + defer cancel() + scanMu.Lock() defer scanMu.Unlock() @@ -109,31 +186,46 @@ func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) defer previous.restore() var ( - mu sync.Mutex - results []Result + errMu sync.Mutex + handleMu sync.Mutex + handlerErr error ) for _, target := range targets { if err := ctx.Err(); err != nil { - return snapshotResults(&mu, results), err + if stored := getHandlerError(&errMu, &handlerErr); stored != nil { + return stored + } + return err } sink := func(raw *output.ScanResult) error { if result, ok := convertOutputResult(raw); ok { - mu.Lock() - results = append(results, result) - mu.Unlock() + 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 snapshotResults(&mu, results), err + return err + } + if stored := getHandlerError(&errMu, &handlerErr); stored != nil { + return stored } } - return snapshotResults(&mu, results), ctx.Err() + 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 { @@ -177,15 +269,11 @@ 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 - } + for _, name := range normalizePlugins(config.Plugins) { if !plugins.Exists(name) { return fmt.Errorf("fscan: plugin %q not found", name) } - if !config.AllowUnsafePlugins && !isSafePlugin(name) { + if !config.AllowUnsafePlugins && !IsSafePlugin(name) { return fmt.Errorf("fscan: plugin %q is not enabled for embedded safe mode", name) } } @@ -293,40 +381,16 @@ func buildFlagVars(config Config, target Target) *common.FlagVars { } 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(pluginNames)) - for _, plugin := range pluginNames { - plugin = strings.TrimSpace(plugin) - if plugin != "" { - parts = append(parts, plugin) - } - } + parts := normalizePlugins(config.Plugins) if len(parts) == 0 { - return "all" + if config.AllowUnsafePlugins { + return "all" + } + parts = defaultSafePlugins } 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 @@ -340,6 +404,43 @@ func formatPorts(ports []int) string { 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) @@ -371,6 +472,20 @@ func snapshotResults(mu *sync.Mutex, results []Result) []Result { 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 +} + type runtimeSnapshot struct { flagVars common.FlagVars config *common.Config diff --git a/pkg/fscan/scanner_test.go b/pkg/fscan/scanner_test.go index 01e4a72..d4fcd47 100644 --- a/pkg/fscan/scanner_test.go +++ b/pkg/fscan/scanner_test.go @@ -2,6 +2,7 @@ package fscan import ( "context" + "errors" "net" "strings" "sync/atomic" @@ -29,6 +30,14 @@ func TestBuildFlagVarsDefaults(t *testing.T) { } } +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}}) @@ -53,11 +62,57 @@ func TestValidateConfig(t *testing.T) { 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") + } +} + func TestScanHonorsCanceledContext(t *testing.T) { scanner := NewScanner(Config{ DisablePing: true, @@ -76,28 +131,9 @@ func TestScanHonorsCanceledContext(t *testing.T) { } func TestScanCollectsResultsThroughSessionSink(t *testing.T) { - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } + listener := startFTPListener(t) 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) @@ -130,10 +166,10 @@ func TestScanCollectsResultsThroughSessionSink(t *testing.T) { if got := atomic.LoadInt32(&streamed); got != int32(len(results)) { t.Fatalf("streamed length = %d, want %d", got, len(results)) } - if !hasResult(results, "PORT", "open", "") { + if !hasResult(results, ResultTypePort, "open", "") { t.Fatalf("missing port result: %#v", results) } - if !hasResult(results, "SERVICE", "FTP", "ftp") { + if !hasResult(results, ResultTypeService, "FTP", "ftp") { t.Fatalf("missing ftp plugin result: %#v", results) } if got := atomic.LoadInt32(&callbackCalls); got != 0 { @@ -141,6 +177,114 @@ func TestScanCollectsResultsThroughSessionSink(t *testing.T) { } } +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 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 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) { diff --git a/pkg/fscan/types.go b/pkg/fscan/types.go index 6148b22..1794eba 100644 --- a/pkg/fscan/types.go +++ b/pkg/fscan/types.go @@ -4,6 +4,26 @@ 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 { @@ -18,6 +38,31 @@ type CredentialPair struct { 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 {