polish scanner SDK API and docs

This commit is contained in:
ZacharyZcR
2026-05-18 15:49:57 +08:00
parent 6605c93dd9
commit d4ed0867c9
9 changed files with 836 additions and 71 deletions
+40
View File
@@ -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)
}
}
}
+49
View File
@@ -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)
}
+29 -5
View File
@@ -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.
+8
View File
@@ -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
+218
View File
@@ -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)
}
+122
View File
@@ -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)
}
}
+159 -44
View File
@@ -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
+166 -22
View File
@@ -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) {
+45
View File
@@ -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 {