merge sdk architecture polish

This commit is contained in:
ZacharyZcR
2026-05-18 21:41:01 +08:00
9 changed files with 490 additions and 55 deletions
+36 -2
View File
@@ -17,6 +17,20 @@ import (
"github.com/shadow1ng/fscan/webscan/lib" "github.com/shadow1ng/fscan/webscan/lib"
) )
// ScanReport summarizes one scan execution.
type ScanReport struct {
Duration time.Duration
TasksTotal int64
TasksCompleted int64
Packets int64
TCPPackets int64
TCPSuccessPackets int64
TCPFailedPackets int64
UDPPackets int64
HTTPPackets int64
ResourceExhausted int64
}
// ScanStrategy 定义扫描策略接口 // ScanStrategy 定义扫描策略接口
type ScanStrategy interface { type ScanStrategy interface {
Execute(ctx context.Context, session *common.ScanSession, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup) Execute(ctx context.Context, session *common.ScanSession, info common.HostInfo, ch chan struct{}, wg *sync.WaitGroup)
@@ -78,7 +92,8 @@ func selectStrategy(config *common.Config, state *common.State, info common.Host
} }
// RunScan 执行整体扫描流程 // RunScan 执行整体扫描流程
func RunScan(ctx context.Context, info common.HostInfo, session *common.ScanSession) { func RunScan(ctx context.Context, info common.HostInfo, session *common.ScanSession) (ScanReport, error) {
start := time.Now()
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
defer cancel() defer cancel()
@@ -88,7 +103,7 @@ func RunScan(ctx context.Context, info common.HostInfo, session *common.ScanSess
// 初始化HTTP客户端(静默,无需日志) // 初始化HTTP客户端(静默,无需日志)
if err := lib.Inithttp(config); err != nil { if err := lib.Inithttp(config); err != nil {
session.LogError(i18n.Tr("http_client_init_failed", err)) session.LogError(i18n.Tr("http_client_init_failed", err))
return return buildScanReport(state, start), fmt.Errorf("initialize http client: %w", err)
} }
// 选择策略 // 选择策略
@@ -131,6 +146,25 @@ func RunScan(ctx context.Context, info common.HostInfo, session *common.ScanSess
// 完成扫描 // 完成扫描
finishScan(session) finishScan(session)
if err := ctx.Err(); err != nil {
return buildScanReport(state, start), err
}
return buildScanReport(state, start), nil
}
func buildScanReport(state *common.State, start time.Time) ScanReport {
return ScanReport{
Duration: time.Since(start),
TasksTotal: state.GetEnd(),
TasksCompleted: state.GetNum(),
Packets: state.GetPacketCount(),
TCPPackets: state.GetTCPPacketCount(),
TCPSuccessPackets: state.GetTCPSuccessPacketCount(),
TCPFailedPackets: state.GetTCPFailedPacketCount(),
UDPPackets: state.GetUDPPacketCount(),
HTTPPackets: state.GetHTTPPacketCount(),
ResourceExhausted: state.GetResourceExhaustedCount(),
}
} }
// finishScan 完成扫描并输出结果 // finishScan 完成扫描并输出结果
+4 -1
View File
@@ -68,5 +68,8 @@ func main() {
defer common.CloseLogger() defer common.CloseLogger()
// 执行扫描 // 执行扫描
core.RunScan(context.Background(), *result.Info, result.Session) if _, err := core.RunScan(context.Background(), *result.Info, result.Session); err != nil {
common.LogError(i18n.Tr("error_generic", err))
os.Exit(1)
}
} }
+22 -17
View File
@@ -32,19 +32,22 @@ func main() {
panic(err) panic(err)
} }
scanner := fscan.NewScanner(config) scanner := fscan.NewScanner(config)
err := scanner.ScanEach(context.Background(), func(result fscan.Result) error { report, err := scanner.ScanReport(context.Background(), fscan.Target{
// Store, forward, or filter the result in the embedding system. Host: "192.168.1.10",
return nil Ports: []int{22, 3306, 6379},
}, fscan.Target{ })
Host: "192.168.1.10", if err != nil {
Ports: []int{22, 3306, 6379}, panic(err)
}) }
if err != nil {
panic(err)
}
fmt.Println("scan finished") fmt.Printf("scan finished: %+v stats=%+v\n", report.Summary, report.Stats)
for _, result := range report.Results {
// Store, forward, or filter the result in the embedding system.
if credential, ok := result.AsCredential(); ok {
fmt.Printf("weak credential: %s %s:%s\n", credential.Target, credential.Username, credential.Password)
}
}
} }
``` ```
@@ -56,11 +59,13 @@ By default, the SDK runs a conservative service-oriented plugin set and blocks p
| Area | API | | Area | API |
| --- | --- | | --- | --- |
| Scanning | `NewScanner`, `Scan`, `ScanEach` | | Scanning | `NewScanner`, `Scan`, `ScanEach`, `ScanReport` |
| Configuration | `Config`, `Target`, `CredentialPair`, `ValidateConfig` | | Configuration | `Config`, `Target`, `CredentialPair`, `ValidateConfig` |
| Plugins | `DefaultSafePlugins`, `ListPlugins`, `GetPlugin`, `IsSafePlugin`, `PluginInfo` | | Plugins | `DefaultSafePlugins`, `ListPlugins`, `GetPlugin`, `IsSafePlugin`, `PluginCapabilities`, `PluginInfo` |
| Results | `Result`, `ResultTypeHost`, `ResultTypePort`, `ResultTypeService`, `ResultTypeVuln` | | Results | `Result`, `ResultTypeHost`, `ResultTypePort`, `ResultTypeService`, `ResultTypeVuln` |
| Result helpers | `Port`, `Service`, `Plugin`, `Username`, `Password`, `Banner`, `Vulnerability`, `URL`, `Protocol`, `IsWeb`, `IsCredential` | | Result helpers | `Port`, `Service`, `Plugin`, `Username`, `Password`, `Banner`, `Vulnerability`, `URL`, `Protocol`, `IsWeb`, `IsCredential`, `AsPort`, `AsService`, `AsCredential`, `AsVulnerability` |
| Summary | `SummarizeResults`, `ResultSummary.Add` | | Summary and stats | `ScanReport`, `ScanStats`, `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. Use `Scan` when you want all results returned as a slice. Use `ScanReport` when the embedding system also needs summary counts and runtime counters. 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.
Plugin capabilities are exposed as stable strings: `detect`, `auth-check`, `brute`, `poc`, and `local-effect`. Default embedded safe mode blocks `poc` and `local-effect` plugins unless `AllowUnsafePlugins` is set.
+69
View File
@@ -192,6 +192,75 @@ func (r Result) IsWeb() bool {
return false return false
} }
// AsPort returns a typed port result when the result describes an open port.
func (r Result) AsPort() (PortResult, bool) {
if !r.IsPort() {
return PortResult{}, false
}
port, ok := r.Port()
if !ok {
return PortResult{}, false
}
return PortResult{Target: r.Target, Port: port}, true
}
// AsService returns a typed service result when service-like fields are present.
func (r Result) AsService() (ServiceResult, bool) {
if !r.IsService() {
return ServiceResult{}, false
}
service := ServiceResult{
Target: r.Target,
IsWeb: r.IsWeb(),
}
if port, ok := r.Port(); ok {
service.Port = port
}
service.Service, _ = r.Service()
service.Banner, _ = r.Banner()
service.Product, _ = r.DetailString("product")
service.Version, _ = r.DetailString("version")
service.Protocol, _ = r.Protocol()
service.URL, _ = r.URL()
return service, service.Service != "" || service.Banner != "" || service.URL != "" || service.Port != 0
}
// AsCredential returns a typed credential result when the result is a weak credential.
func (r Result) AsCredential() (CredentialResult, bool) {
if !r.IsCredential() {
return CredentialResult{}, false
}
username, userOK := r.Username()
password, passOK := r.Password()
if !userOK && !passOK {
return CredentialResult{}, false
}
service, _ := r.Service()
return CredentialResult{
Target: r.Target,
Service: service,
Username: username,
Password: password,
}, true
}
// AsVulnerability returns a typed vulnerability result when vulnerability data is present.
func (r Result) AsVulnerability() (VulnerabilityResult, bool) {
if !r.IsVuln() || r.IsCredential() {
return VulnerabilityResult{}, false
}
vulnerability, ok := r.Vulnerability()
if !ok || vulnerability == "" {
return VulnerabilityResult{}, false
}
service, _ := r.Service()
return VulnerabilityResult{
Target: r.Target,
Service: service,
Vulnerability: vulnerability,
}, true
}
func intFromInt64(v int64) (int, bool) { func intFromInt64(v int64) (int, bool) {
max := int64(^uint(0) >> 1) max := int64(^uint(0) >> 1)
min := -max - 1 min := -max - 1
+58 -1
View File
@@ -59,9 +59,11 @@ func TestResultPortDoesNotParseBareIPv6(t *testing.T) {
func TestResultCredentialHelpers(t *testing.T) { func TestResultCredentialHelpers(t *testing.T) {
result := Result{ result := Result{
Type: ResultTypeVuln, Type: ResultTypeVuln,
Target: "127.0.0.1:22",
Details: map[string]interface{}{ Details: map[string]interface{}{
"type": "weak_credential", "type": "weak_credential",
"service": "ssh",
"username": "root", "username": "root",
"password": "toor", "password": "toor",
}, },
@@ -81,6 +83,61 @@ func TestResultCredentialHelpers(t *testing.T) {
} }
} }
func TestTypedResultViews(t *testing.T) {
portResult, ok := (Result{
Type: ResultTypePort,
Target: "127.0.0.1",
Details: map[string]interface{}{"port": 22},
}).AsPort()
if !ok || portResult.Port != 22 || portResult.Target != "127.0.0.1" {
t.Fatalf("AsPort = %#v/%v, want port 22", portResult, ok)
}
serviceResult, ok := (Result{
Type: ResultTypeService,
Target: "127.0.0.1:80",
Details: map[string]interface{}{
"port": 80,
"service": "http",
"banner": "nginx",
"product": "nginx",
"version": "1.25",
"is_web": true,
"protocol": "http",
"url": "http://127.0.0.1:80",
},
}).AsService()
if !ok || serviceResult.Service != "http" || serviceResult.Port != 80 || !serviceResult.IsWeb {
t.Fatalf("AsService = %#v/%v, want http web service", serviceResult, ok)
}
credentialResult, ok := (Result{
Type: ResultTypeVuln,
Target: "127.0.0.1:22",
Details: map[string]interface{}{
"type": "weak_credential",
"service": "ssh",
"username": "root",
"password": "toor",
},
}).AsCredential()
if !ok || credentialResult.Username != "root" || credentialResult.Password != "toor" {
t.Fatalf("AsCredential = %#v/%v, want root/toor", credentialResult, ok)
}
vulnResult, ok := (Result{
Type: ResultTypeVuln,
Target: "127.0.0.1:25",
Details: map[string]interface{}{
"service": "smtp",
"vulnerability": "open relay",
},
}).AsVulnerability()
if !ok || vulnResult.Service != "smtp" || vulnResult.Vulnerability != "open relay" {
t.Fatalf("AsVulnerability = %#v/%v, want smtp/open relay", vulnResult, ok)
}
}
func TestSummarizeResults(t *testing.T) { func TestSummarizeResults(t *testing.T) {
results := []Result{ results := []Result{
{Type: ResultTypeHost, Target: "127.0.0.1"}, {Type: ResultTypeHost, Target: "127.0.0.1"},
+154 -24
View File
@@ -85,11 +85,12 @@ func GetPlugin(name string) (PluginInfo, bool) {
return PluginInfo{}, false return PluginInfo{}, false
} }
return PluginInfo{ return PluginInfo{
Name: name, Name: name,
Types: pluginTypes(name), Types: pluginTypes(name),
Ports: pluginPorts(name), Capabilities: PluginCapabilities(name),
Safe: IsSafePlugin(name), Ports: pluginPorts(name),
Default: isDefaultSafePlugin(name), Safe: IsSafePlugin(name),
Default: isDefaultSafePlugin(name),
}, true }, true
} }
@@ -109,31 +110,56 @@ func IsSafePlugin(name string) bool {
if name == "" || !plugins.Exists(name) { if name == "" || !plugins.Exists(name) {
return false return false
} }
return plugins.IsSafe(name) return plugins.IsSafe(name) && !hasPluginCapability(name, PluginCapabilityPOC, PluginCapabilityLocalEffect)
}
// PluginCapabilities returns the SDK-facing behavior classes for a plugin.
func PluginCapabilities(name string) []string {
name = strings.TrimSpace(name)
if name == "" || !plugins.Exists(name) {
return nil
}
return pluginCapabilities(name)
} }
// 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) {
report, err := s.ScanReport(ctx, targets...)
return report.Results, err
}
// ScanReport runs the scanner and returns results with summary and runtime stats.
func (s *Scanner) ScanReport(ctx context.Context, targets ...Target) (ScanReport, error) {
var ( var (
mu sync.Mutex mu sync.Mutex
results []Result results []Result
) )
err := s.ScanEach(ctx, func(result Result) error { stats, err := s.scanEach(ctx, func(result Result) error {
mu.Lock() mu.Lock()
results = append(results, result) results = append(results, result)
mu.Unlock() mu.Unlock()
return nil return nil
}, targets...) }, targets...)
return snapshotResults(&mu, results), err results = snapshotResults(&mu, results)
return ScanReport{
Results: results,
Summary: SummarizeResults(results),
Stats: stats,
}, err
} }
// ScanEach runs the scanner and calls handle serially for each structured // ScanEach runs the scanner and calls handle serially for each structured
// result without retaining all results in memory. If handle returns an error, // result without retaining all results in memory. If handle returns an error,
// the scan context is canceled and that error is returned. // the scan context is canceled and that error is returned.
func (s *Scanner) ScanEach(ctx context.Context, handle ResultHandler, targets ...Target) error { func (s *Scanner) ScanEach(ctx context.Context, handle ResultHandler, targets ...Target) error {
_, err := s.scanEach(ctx, handle, targets...)
return err
}
func (s *Scanner) scanEach(ctx context.Context, handle ResultHandler, targets ...Target) (ScanStats, error) {
if handle == nil { if handle == nil {
return fmt.Errorf("fscan: result handler is required") return ScanStats{}, fmt.Errorf("fscan: result handler is required")
} }
if ctx == nil { if ctx == nil {
ctx = context.Background() ctx = context.Background()
@@ -142,7 +168,7 @@ func (s *Scanner) ScanEach(ctx context.Context, handle ResultHandler, targets ..
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 err return ScanStats{}, err
} }
ctx, cancel := context.WithCancel(ctx) ctx, cancel := context.WithCancel(ctx)
@@ -155,13 +181,14 @@ func (s *Scanner) ScanEach(ctx context.Context, handle ResultHandler, targets ..
handleMu sync.Mutex handleMu sync.Mutex
handlerErr error handlerErr error
) )
var stats ScanStats
for _, target := range targets { for _, target := range targets {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
if stored := getHandlerError(&errMu, &handlerErr); stored != nil { if stored := getHandlerError(&errMu, &handlerErr); stored != nil {
return stored return stats, stored
} }
return err return stats, 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 {
@@ -179,31 +206,38 @@ func (s *Scanner) ScanEach(ctx context.Context, handle ResultHandler, targets ..
} }
return nil return nil
} }
if err := s.scanOne(ctx, target, sink); err != nil { report, err := s.scanOne(ctx, target, sink)
return err stats.add(coreStatsToSDK(report))
if err != nil {
if stored := getHandlerError(&errMu, &handlerErr); stored != nil {
return stats, stored
}
return stats, err
} }
if stored := getHandlerError(&errMu, &handlerErr); stored != nil { if stored := getHandlerError(&errMu, &handlerErr); stored != nil {
return stored return stats, stored
} }
} }
if stored := getHandlerError(&errMu, &handlerErr); stored != nil { if stored := getHandlerError(&errMu, &handlerErr); stored != nil {
return stored return stats, stored
} }
return ctx.Err() return stats, 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) (core.ScanReport, error) {
fv := buildFlagVars(s.config, target) fv := buildFlagVars(s.config, target)
info := common.HostInfo{Host: strings.TrimSpace(target.Host), URL: strings.TrimSpace(target.URL)} info := common.HostInfo{Host: strings.TrimSpace(target.Host), URL: strings.TrimSpace(target.URL)}
previousLanguage := i18n.GetLanguage() if strings.TrimSpace(s.config.Language) != "" {
i18n.SetLanguage(fv.Language) previousLanguage := i18n.GetLanguage()
defer i18n.SetLanguage(previousLanguage) i18n.SetLanguage(fv.Language)
defer i18n.SetLanguage(previousLanguage)
}
cfg, state, err := common.BuildConfig(fv, &info) cfg, state, err := common.BuildConfig(fv, &info)
if err != nil { if err != nil {
return err return core.ScanReport{}, err
} }
if len(s.config.UserPassPairs) > 0 { if len(s.config.UserPassPairs) > 0 {
cfg.Credentials.UserPassPairs = make([]commonconfig.CredentialPair, 0, len(s.config.UserPassPairs)) cfg.Credentials.UserPassPairs = make([]commonconfig.CredentialPair, 0, len(s.config.UserPassPairs))
@@ -221,8 +255,7 @@ func (s *Scanner) scanOne(ctx context.Context, target Target, sink common.Result
session := common.NewScanSession(cfg, state, fv) session := common.NewScanSession(cfg, state, fv)
session.ResultSink = sink session.ResultSink = sink
core.RunScan(ctx, info, session) return core.RunScan(ctx, info, session)
return nil
} }
func validateConfig(config Config, targets []Target) error { func validateConfig(config Config, targets []Target) error {
@@ -392,6 +425,75 @@ func pluginPorts(name string) []int {
return ports return ports
} }
func pluginCapabilities(name string) []string {
capSet := map[string]struct{}{}
add := func(capability string) {
capSet[capability] = struct{}{}
}
if plugins.HasType(name, PluginTypeService) || plugins.HasType(name, PluginTypeWeb) {
add(PluginCapabilityDetect)
}
if serviceAuthPlugins[name] {
add(PluginCapabilityAuthCheck)
add(PluginCapabilityBrute)
}
if activePOCPlugins[name] || strings.Contains(name, "poc") {
add(PluginCapabilityPOC)
}
if plugins.HasType(name, PluginTypeLocal) {
add(PluginCapabilityLocalEffect)
}
capabilities := make([]string, 0, len(capSet))
for capability := range capSet {
capabilities = append(capabilities, capability)
}
sort.Strings(capabilities)
return capabilities
}
func hasPluginCapability(name string, capabilities ...string) bool {
pluginCaps := pluginCapabilities(name)
for _, want := range capabilities {
for _, got := range pluginCaps {
if got == want {
return true
}
}
}
return false
}
var serviceAuthPlugins = map[string]bool{
"activemq": true,
"cassandra": true,
"elasticsearch": true,
"ftp": true,
"kafka": true,
"ldap": true,
"memcached": true,
"mongodb": true,
"mssql": true,
"mysql": true,
"neo4j": true,
"oracle": true,
"postgresql": true,
"rabbitmq": true,
"redis": true,
"rsync": true,
"smb": true,
"smtp": true,
"ssh": true,
"telnet": true,
"vnc": true,
}
var activePOCPlugins = map[string]bool{
"ms17010": true,
"webpoc": true,
}
func isDefaultSafePlugin(name string) bool { func isDefaultSafePlugin(name string) bool {
for _, plugin := range defaultSafePlugins { for _, plugin := range defaultSafePlugins {
if plugin == name { if plugin == name {
@@ -426,6 +528,34 @@ func convertOutputResult(raw *output.ScanResult) (Result, bool) {
return result, result.Target != "" || result.Status != "" return result, result.Target != "" || result.Status != ""
} }
func coreStatsToSDK(report core.ScanReport) ScanStats {
return ScanStats{
Duration: report.Duration,
TasksTotal: report.TasksTotal,
TasksCompleted: report.TasksCompleted,
Packets: report.Packets,
TCPPackets: report.TCPPackets,
TCPSuccessPackets: report.TCPSuccessPackets,
TCPFailedPackets: report.TCPFailedPackets,
UDPPackets: report.UDPPackets,
HTTPPackets: report.HTTPPackets,
ResourceExhausted: report.ResourceExhausted,
}
}
func (s *ScanStats) add(other ScanStats) {
s.Duration += other.Duration
s.TasksTotal += other.TasksTotal
s.TasksCompleted += other.TasksCompleted
s.Packets += other.Packets
s.TCPPackets += other.TCPPackets
s.TCPSuccessPackets += other.TCPSuccessPackets
s.TCPFailedPackets += other.TCPFailedPackets
s.UDPPackets += other.UDPPackets
s.HTTPPackets += other.HTTPPackets
s.ResourceExhausted += other.ResourceExhausted
}
func snapshotResults(mu *sync.Mutex, results []Result) []Result { func snapshotResults(mu *sync.Mutex, results []Result) []Result {
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
+65
View File
@@ -79,6 +79,9 @@ func TestIsSafePlugin(t *testing.T) {
if IsSafePlugin("webpoc") { if IsSafePlugin("webpoc") {
t.Fatal("webpoc should not be safe") t.Fatal("webpoc should not be safe")
} }
if IsSafePlugin("ms17010") {
t.Fatal("active poc plugins should not be safe")
}
if IsSafePlugin("definitely-missing") { if IsSafePlugin("definitely-missing") {
t.Fatal("unknown plugin should not be safe") t.Fatal("unknown plugin should not be safe")
} }
@@ -107,6 +110,9 @@ func TestListPlugins(t *testing.T) {
if !containsString(ssh.Types, PluginTypeService) { if !containsString(ssh.Types, PluginTypeService) {
t.Fatalf("ssh types = %#v, want service", ssh.Types) t.Fatalf("ssh types = %#v, want service", ssh.Types)
} }
if !containsString(ssh.Capabilities, PluginCapabilityDetect) || !containsString(ssh.Capabilities, PluginCapabilityAuthCheck) {
t.Fatalf("ssh capabilities = %#v, want detect/auth-check", ssh.Capabilities)
}
if !containsInt(ssh.Ports, 22) { if !containsInt(ssh.Ports, 22) {
t.Fatalf("ssh ports = %#v, want 22", ssh.Ports) t.Fatalf("ssh ports = %#v, want 22", ssh.Ports)
} }
@@ -123,6 +129,9 @@ func TestListPlugins(t *testing.T) {
if !containsString(webpoc.Types, PluginTypeWeb) { if !containsString(webpoc.Types, PluginTypeWeb) {
t.Fatalf("webpoc types = %#v, want web", webpoc.Types) t.Fatalf("webpoc types = %#v, want web", webpoc.Types)
} }
if !containsString(webpoc.Capabilities, PluginCapabilityPOC) {
t.Fatalf("webpoc capabilities = %#v, want poc", webpoc.Capabilities)
}
} }
func TestScanHonorsCanceledContext(t *testing.T) { func TestScanHonorsCanceledContext(t *testing.T) {
@@ -247,6 +256,37 @@ func TestScanUsesConfigTargets(t *testing.T) {
} }
} }
func TestScanReportReturnsSummaryAndStats(t *testing.T) {
listener := startFTPListener(t)
defer listener.Close()
port := listener.Addr().(*net.TCPAddr).Port
scanner := NewScanner(Config{
DisablePing: true,
DisableBrute: true,
Timeout: time.Second,
Threads: 16,
Plugins: []string{"ftp"},
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
report, err := scanner.ScanReport(ctx, Target{Host: "127.0.0.1", Ports: []int{port}})
if err != nil {
t.Fatal(err)
}
if len(report.Results) == 0 || report.Summary.Total != len(report.Results) {
t.Fatalf("report summary/results mismatch: %#v", report)
}
if report.Stats.Duration <= 0 {
t.Fatalf("report duration = %s, want positive", report.Stats.Duration)
}
if report.Stats.TasksCompleted == 0 {
t.Fatalf("report stats = %#v, want completed tasks", report.Stats)
}
}
func TestScanExplicitTargetsOverrideConfigTargets(t *testing.T) { func TestScanExplicitTargetsOverrideConfigTargets(t *testing.T) {
configured := startFTPListener(t) configured := startFTPListener(t)
defer configured.Close() defer configured.Close()
@@ -441,6 +481,31 @@ func TestScanDoesNotReplaceGlobalRuntime(t *testing.T) {
} }
} }
func TestScanWithoutLanguageDoesNotTouchGlobalLanguage(t *testing.T) {
listener := startFTPListener(t)
defer listener.Close()
previousLanguage := i18n.GetLanguage()
defer i18n.SetLanguage(previousLanguage)
i18n.SetLanguage(i18n.LangEN)
scanner := NewScanner(Config{
DisablePing: true,
DisableBrute: true,
Timeout: time.Second,
Threads: 16,
Plugins: []string{"ftp"},
})
port := listener.Addr().(*net.TCPAddr).Port
if _, err := scanner.Scan(context.Background(), Target{Host: "127.0.0.1", Ports: []int{port}}); err != nil {
t.Fatal(err)
}
if got := i18n.GetLanguage(); got != i18n.LangEN {
t.Fatalf("SDK scan leaked global language = %q, want %q", got, i18n.LangEN)
}
}
func startFTPListener(t *testing.T) net.Listener { func startFTPListener(t *testing.T) net.Listener {
t.Helper() t.Helper()
+74 -5
View File
@@ -13,6 +13,19 @@ const (
PluginTypeService = "service" PluginTypeService = "service"
) )
const (
// PluginCapabilityDetect marks passive or low-impact detection behavior.
PluginCapabilityDetect = "detect"
// PluginCapabilityAuthCheck marks credential validation behavior.
PluginCapabilityAuthCheck = "auth-check"
// PluginCapabilityBrute marks dictionary-style credential attempts.
PluginCapabilityBrute = "brute"
// PluginCapabilityPOC marks active vulnerability checks or exploitation.
PluginCapabilityPOC = "poc"
// PluginCapabilityLocalEffect marks plugins that change or inspect local host state.
PluginCapabilityLocalEffect = "local-effect"
)
const ( const (
// ResultTypeHost reports a live host. // ResultTypeHost reports a live host.
ResultTypeHost = "HOST" ResultTypeHost = "HOST"
@@ -40,11 +53,12 @@ type CredentialPair struct {
// PluginInfo describes one registered scanner plugin. // PluginInfo describes one registered scanner plugin.
type PluginInfo struct { type PluginInfo struct {
Name string `json:"name"` Name string `json:"name"`
Types []string `json:"types,omitempty"` Types []string `json:"types,omitempty"`
Ports []int `json:"ports,omitempty"` Capabilities []string `json:"capabilities,omitempty"`
Safe bool `json:"safe"` Ports []int `json:"ports,omitempty"`
Default bool `json:"default"` Safe bool `json:"safe"`
Default bool `json:"default"`
} }
// ResultSummary counts common result categories. // ResultSummary counts common result categories.
@@ -58,6 +72,27 @@ type ResultSummary struct {
Credentials int `json:"credentials"` Credentials int `json:"credentials"`
} }
// ScanStats reports runtime counters for one embedded scan call.
type ScanStats struct {
Duration time.Duration `json:"duration"`
TasksTotal int64 `json:"tasks_total"`
TasksCompleted int64 `json:"tasks_completed"`
Packets int64 `json:"packets"`
TCPPackets int64 `json:"tcp_packets"`
TCPSuccessPackets int64 `json:"tcp_success_packets"`
TCPFailedPackets int64 `json:"tcp_failed_packets"`
UDPPackets int64 `json:"udp_packets"`
HTTPPackets int64 `json:"http_packets"`
ResourceExhausted int64 `json:"resource_exhausted"`
}
// ScanReport returns structured results with summary and runtime counters.
type ScanReport struct {
Results []Result `json:"results"`
Summary ResultSummary `json:"summary"`
Stats ScanStats `json:"stats"`
}
// ResultHandler receives one structured result. Calls are serialized by the // ResultHandler receives one structured result. Calls are serialized by the
// scanner. Returning an error asks the scanner to stop and returns that error // scanner. Returning an error asks the scanner to stop and returns that error
// to the caller. // to the caller.
@@ -114,3 +149,37 @@ type Result struct {
Status string `json:"status"` Status string `json:"status"`
Details map[string]interface{} `json:"details,omitempty"` Details map[string]interface{} `json:"details,omitempty"`
} }
// PortResult is a typed view over an open port result.
type PortResult struct {
Target string `json:"target"`
Port int `json:"port"`
}
// ServiceResult is a typed view over a service or web detection result.
type ServiceResult struct {
Target string `json:"target"`
Port int `json:"port,omitempty"`
Service string `json:"service,omitempty"`
Banner string `json:"banner,omitempty"`
Product string `json:"product,omitempty"`
Version string `json:"version,omitempty"`
Protocol string `json:"protocol,omitempty"`
URL string `json:"url,omitempty"`
IsWeb bool `json:"is_web,omitempty"`
}
// CredentialResult is a typed view over a weak credential result.
type CredentialResult struct {
Target string `json:"target"`
Service string `json:"service,omitempty"`
Username string `json:"username"`
Password string `json:"password"`
}
// VulnerabilityResult is a typed view over a vulnerability result.
type VulnerabilityResult struct {
Target string `json:"target"`
Service string `json:"service,omitempty"`
Vulnerability string `json:"vulnerability"`
}
+8 -5
View File
@@ -5,6 +5,7 @@ package api
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -47,10 +48,10 @@ type ScanRequest struct {
Domain string `json:"domain"` Domain string `json:"domain"`
// POC // POC
PocPath string `json:"poc_path"` PocPath string `json:"poc_path"`
PocName string `json:"poc_name"` PocName string `json:"poc_name"`
PocFull bool `json:"poc_full"` PocFull bool `json:"poc_full"`
DisablePoc bool `json:"disable_poc"` DisablePoc bool `json:"disable_poc"`
// 项目缓存 // 项目缓存
ProjectID string `json:"project_id,omitempty"` ProjectID string `json:"project_id,omitempty"`
@@ -234,7 +235,9 @@ func (h *ScanHandler) runScan(req ScanRequest) {
}) })
// 执行扫描 // 执行扫描
core.RunScan(ctx, info, session) if _, err := core.RunScan(ctx, info, session); err != nil && !errors.Is(err, context.Canceled) {
common.LogError(err.Error())
}
// 项目缓存回写:合并本次扫描结果 // 项目缓存回写:合并本次扫描结果
if req.ProjectID != "" { if req.ProjectID != "" {