mirror of
https://github.com/shadow1ng/fscan.git
synced 2026-09-22 03:10:42 +08:00
polish scanner SDK API and docs
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+28
-4
@@ -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) {
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+158
-43
@@ -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 {
|
||||
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
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user