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)
|
||||||
|
}
|
||||||
+29
-5
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
`pkg/fscan` exposes fscan as an embeddable Go scanner while keeping the CLI unchanged.
|
`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
|
```go
|
||||||
package main
|
package main
|
||||||
|
|
||||||
@@ -14,18 +16,27 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
scanner := fscan.NewScanner(fscan.Config{
|
config := fscan.Config{
|
||||||
Timeout: 3 * time.Second,
|
Timeout: 3 * time.Second,
|
||||||
Threads: 128,
|
Threads: 128,
|
||||||
DisablePing: true,
|
DisablePing: true,
|
||||||
DisableBrute: true,
|
DisableBrute: true,
|
||||||
Plugins: []string{"ssh", "mysql", "redis"},
|
Plugins: []string{"ssh", "mysql", "redis"},
|
||||||
OnResult: func(result fscan.Result) {
|
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",
|
Host: "192.168.1.10",
|
||||||
Ports: []int{22, 3306, 6379},
|
Ports: []int{22, 3306, 6379},
|
||||||
})
|
})
|
||||||
@@ -33,10 +44,23 @@ func main() {
|
|||||||
panic(err)
|
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.
|
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.
|
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
|
// plugin registry, while hiding CLI flags, stdout output, and result files from
|
||||||
// callers. The first SDK surface is serialized internally because the current
|
// callers. The first SDK surface is serialized internally because the current
|
||||||
// scan core still uses process-wide runtime state.
|
// 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
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+159
-44
@@ -89,9 +89,83 @@ func DefaultSafePlugins() []string {
|
|||||||
return append([]string(nil), defaultSafePlugins...)
|
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
|
// Scan runs the scanner for the provided targets and returns structured
|
||||||
// findings. If no targets are provided, Config.Targets is used.
|
// findings. If no targets are provided, Config.Targets is used.
|
||||||
func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error) {
|
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 {
|
if ctx == nil {
|
||||||
ctx = context.Background()
|
ctx = context.Background()
|
||||||
}
|
}
|
||||||
@@ -99,9 +173,12 @@ func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error)
|
|||||||
targets = s.config.Targets
|
targets = s.config.Targets
|
||||||
}
|
}
|
||||||
if err := validateConfig(s.config, targets); err != nil {
|
if err := validateConfig(s.config, targets); err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
scanMu.Lock()
|
scanMu.Lock()
|
||||||
defer scanMu.Unlock()
|
defer scanMu.Unlock()
|
||||||
|
|
||||||
@@ -109,31 +186,46 @@ func (s *Scanner) Scan(ctx context.Context, targets ...Target) ([]Result, error)
|
|||||||
defer previous.restore()
|
defer previous.restore()
|
||||||
|
|
||||||
var (
|
var (
|
||||||
mu sync.Mutex
|
errMu sync.Mutex
|
||||||
results []Result
|
handleMu sync.Mutex
|
||||||
|
handlerErr error
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, target := range targets {
|
for _, target := range targets {
|
||||||
if err := ctx.Err(); err != nil {
|
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 {
|
sink := func(raw *output.ScanResult) error {
|
||||||
if result, ok := convertOutputResult(raw); ok {
|
if result, ok := convertOutputResult(raw); ok {
|
||||||
mu.Lock()
|
handleMu.Lock()
|
||||||
results = append(results, result)
|
if err := handle(result); err != nil {
|
||||||
mu.Unlock()
|
handleMu.Unlock()
|
||||||
|
setHandlerError(&errMu, &handlerErr, err)
|
||||||
|
cancel()
|
||||||
|
return err
|
||||||
|
}
|
||||||
if s.config.OnResult != nil {
|
if s.config.OnResult != nil {
|
||||||
s.config.OnResult(result)
|
s.config.OnResult(result)
|
||||||
}
|
}
|
||||||
|
handleMu.Unlock()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if err := s.scanOne(ctx, target, sink); err != 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 {
|
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 {
|
if len(targets) == 0 {
|
||||||
return fmt.Errorf("fscan: at least one target is required")
|
return fmt.Errorf("fscan: at least one target is required")
|
||||||
}
|
}
|
||||||
for _, plugin := range config.Plugins {
|
for _, name := range normalizePlugins(config.Plugins) {
|
||||||
name := strings.TrimSpace(plugin)
|
|
||||||
if name == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !plugins.Exists(name) {
|
if !plugins.Exists(name) {
|
||||||
return fmt.Errorf("fscan: plugin %q not found", 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)
|
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 {
|
func formatPlugins(config Config) string {
|
||||||
pluginNames := config.Plugins
|
parts := normalizePlugins(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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
return "all"
|
if config.AllowUnsafePlugins {
|
||||||
|
return "all"
|
||||||
|
}
|
||||||
|
parts = defaultSafePlugins
|
||||||
}
|
}
|
||||||
return strings.Join(parts, ",")
|
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 {
|
func formatPorts(ports []int) string {
|
||||||
if len(ports) == 0 {
|
if len(ports) == 0 {
|
||||||
return commonconfig.MainPorts
|
return commonconfig.MainPorts
|
||||||
@@ -340,6 +404,43 @@ func formatPorts(ports []int) string {
|
|||||||
return strings.Join(parts, ",")
|
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 {
|
func secondsOrDefault(value time.Duration, fallback int) int64 {
|
||||||
if value <= 0 {
|
if value <= 0 {
|
||||||
return int64(fallback)
|
return int64(fallback)
|
||||||
@@ -371,6 +472,20 @@ func snapshotResults(mu *sync.Mutex, results []Result) []Result {
|
|||||||
return append([]Result(nil), results...)
|
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 {
|
type runtimeSnapshot struct {
|
||||||
flagVars common.FlagVars
|
flagVars common.FlagVars
|
||||||
config *common.Config
|
config *common.Config
|
||||||
|
|||||||
+166
-22
@@ -2,6 +2,7 @@ package fscan
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"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) {
|
func TestBuildFlagVarsTargetPortsOverride(t *testing.T) {
|
||||||
fv := buildFlagVars(Config{Ports: []int{22, 80}}, Target{Host: "127.0.0.1", Ports: []int{3306, 22}})
|
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 {
|
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)
|
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 {
|
if err := validateConfig(Config{}, []Target{{Host: "127.0.0.1", Ports: []int{70000}}}); err == nil {
|
||||||
t.Fatal("expected invalid port error")
|
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) {
|
func TestScanHonorsCanceledContext(t *testing.T) {
|
||||||
scanner := NewScanner(Config{
|
scanner := NewScanner(Config{
|
||||||
DisablePing: true,
|
DisablePing: true,
|
||||||
@@ -76,28 +131,9 @@ func TestScanHonorsCanceledContext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestScanCollectsResultsThroughSessionSink(t *testing.T) {
|
func TestScanCollectsResultsThroughSessionSink(t *testing.T) {
|
||||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
listener := startFTPListener(t)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer listener.Close()
|
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
|
var callbackCalls int32
|
||||||
common.SetResultCallback(func(interface{}) {
|
common.SetResultCallback(func(interface{}) {
|
||||||
atomic.AddInt32(&callbackCalls, 1)
|
atomic.AddInt32(&callbackCalls, 1)
|
||||||
@@ -130,10 +166,10 @@ func TestScanCollectsResultsThroughSessionSink(t *testing.T) {
|
|||||||
if got := atomic.LoadInt32(&streamed); got != int32(len(results)) {
|
if got := atomic.LoadInt32(&streamed); got != int32(len(results)) {
|
||||||
t.Fatalf("streamed length = %d, want %d", got, 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)
|
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)
|
t.Fatalf("missing ftp plugin result: %#v", results)
|
||||||
}
|
}
|
||||||
if got := atomic.LoadInt32(&callbackCalls); got != 0 {
|
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 {
|
func hasResult(results []Result, resultType, statusText, plugin string) bool {
|
||||||
for _, result := range results {
|
for _, result := range results {
|
||||||
if result.Type != resultType || !strings.Contains(result.Status, statusText) {
|
if result.Type != resultType || !strings.Contains(result.Status, statusText) {
|
||||||
|
|||||||
@@ -4,6 +4,26 @@ import (
|
|||||||
"time"
|
"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
|
// 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.
|
// scans, or URL for web scans. Ports applies only to Host scans.
|
||||||
type Target struct {
|
type Target struct {
|
||||||
@@ -18,6 +38,31 @@ type CredentialPair struct {
|
|||||||
Password string
|
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
|
// Config controls an embedded scan. Zero values use the same conservative
|
||||||
// defaults as the CLI, except output is silent and file saving is disabled.
|
// defaults as the CLI, except output is silent and file saving is disabled.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user