diff --git a/core/scanner.go b/core/scanner.go index 3fc5148..35564f5 100644 --- a/core/scanner.go +++ b/core/scanner.go @@ -17,6 +17,20 @@ import ( "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 定义扫描策略接口 type ScanStrategy interface { 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 执行整体扫描流程 -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) defer cancel() @@ -88,7 +103,7 @@ func RunScan(ctx context.Context, info common.HostInfo, session *common.ScanSess // 初始化HTTP客户端(静默,无需日志) if err := lib.Inithttp(config); err != nil { 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) + 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 完成扫描并输出结果 diff --git a/main.go b/main.go index 125e582..7fb922f 100644 --- a/main.go +++ b/main.go @@ -68,5 +68,8 @@ func main() { 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) + } } diff --git a/pkg/fscan/README.md b/pkg/fscan/README.md index 63065d7..371a6ab 100644 --- a/pkg/fscan/README.md +++ b/pkg/fscan/README.md @@ -32,19 +32,22 @@ func main() { panic(err) } - 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}, - }) - if err != nil { - panic(err) - } +scanner := fscan.NewScanner(config) +report, err := scanner.ScanReport(context.Background(), fscan.Target{ + Host: "192.168.1.10", + Ports: []int{22, 3306, 6379}, +}) +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 | | --- | --- | -| Scanning | `NewScanner`, `Scan`, `ScanEach` | +| Scanning | `NewScanner`, `Scan`, `ScanEach`, `ScanReport` | | Configuration | `Config`, `Target`, `CredentialPair`, `ValidateConfig` | -| Plugins | `DefaultSafePlugins`, `ListPlugins`, `GetPlugin`, `IsSafePlugin`, `PluginInfo` | +| Plugins | `DefaultSafePlugins`, `ListPlugins`, `GetPlugin`, `IsSafePlugin`, `PluginCapabilities`, `PluginInfo` | | Results | `Result`, `ResultTypeHost`, `ResultTypePort`, `ResultTypeService`, `ResultTypeVuln` | -| Result helpers | `Port`, `Service`, `Plugin`, `Username`, `Password`, `Banner`, `Vulnerability`, `URL`, `Protocol`, `IsWeb`, `IsCredential` | -| Summary | `SummarizeResults`, `ResultSummary.Add` | +| Result helpers | `Port`, `Service`, `Plugin`, `Username`, `Password`, `Banner`, `Vulnerability`, `URL`, `Protocol`, `IsWeb`, `IsCredential`, `AsPort`, `AsService`, `AsCredential`, `AsVulnerability` | +| 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. diff --git a/pkg/fscan/result.go b/pkg/fscan/result.go index 3c7a87b..b5a2594 100644 --- a/pkg/fscan/result.go +++ b/pkg/fscan/result.go @@ -192,6 +192,75 @@ func (r Result) IsWeb() bool { 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) { max := int64(^uint(0) >> 1) min := -max - 1 diff --git a/pkg/fscan/result_test.go b/pkg/fscan/result_test.go index 302171a..851c40b 100644 --- a/pkg/fscan/result_test.go +++ b/pkg/fscan/result_test.go @@ -59,9 +59,11 @@ func TestResultPortDoesNotParseBareIPv6(t *testing.T) { func TestResultCredentialHelpers(t *testing.T) { result := Result{ - Type: ResultTypeVuln, + Type: ResultTypeVuln, + Target: "127.0.0.1:22", Details: map[string]interface{}{ "type": "weak_credential", + "service": "ssh", "username": "root", "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) { results := []Result{ {Type: ResultTypeHost, Target: "127.0.0.1"}, diff --git a/pkg/fscan/scanner.go b/pkg/fscan/scanner.go index 719fbfa..cf0aa74 100644 --- a/pkg/fscan/scanner.go +++ b/pkg/fscan/scanner.go @@ -85,11 +85,12 @@ func GetPlugin(name string) (PluginInfo, bool) { return PluginInfo{}, false } return PluginInfo{ - Name: name, - Types: pluginTypes(name), - Ports: pluginPorts(name), - Safe: IsSafePlugin(name), - Default: isDefaultSafePlugin(name), + Name: name, + Types: pluginTypes(name), + Capabilities: PluginCapabilities(name), + Ports: pluginPorts(name), + Safe: IsSafePlugin(name), + Default: isDefaultSafePlugin(name), }, true } @@ -109,31 +110,56 @@ func IsSafePlugin(name string) bool { if name == "" || !plugins.Exists(name) { 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 // findings. If no targets are provided, Config.Targets is used. 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 ( mu sync.Mutex results []Result ) - err := s.ScanEach(ctx, func(result Result) error { + stats, err := s.scanEach(ctx, func(result Result) error { mu.Lock() results = append(results, result) mu.Unlock() return nil }, 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 // 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 { + _, err := s.scanEach(ctx, handle, targets...) + return err +} + +func (s *Scanner) scanEach(ctx context.Context, handle ResultHandler, targets ...Target) (ScanStats, error) { if handle == nil { - return fmt.Errorf("fscan: result handler is required") + return ScanStats{}, fmt.Errorf("fscan: result handler is required") } if ctx == nil { ctx = context.Background() @@ -142,7 +168,7 @@ func (s *Scanner) ScanEach(ctx context.Context, handle ResultHandler, targets .. targets = s.config.Targets } if err := validateConfig(s.config, targets); err != nil { - return err + return ScanStats{}, err } ctx, cancel := context.WithCancel(ctx) @@ -155,13 +181,14 @@ func (s *Scanner) ScanEach(ctx context.Context, handle ResultHandler, targets .. handleMu sync.Mutex handlerErr error ) + var stats ScanStats for _, target := range targets { if err := ctx.Err(); err != nil { if stored := getHandlerError(&errMu, &handlerErr); stored != nil { - return stored + return stats, stored } - return err + return stats, err } sink := func(raw *output.ScanResult) error { if result, ok := convertOutputResult(raw); ok { @@ -179,31 +206,38 @@ func (s *Scanner) ScanEach(ctx context.Context, handle ResultHandler, targets .. } return nil } - if err := s.scanOne(ctx, target, sink); err != nil { - return err + report, err := s.scanOne(ctx, target, sink) + 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 { - return stored + return stats, stored } } 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) info := common.HostInfo{Host: strings.TrimSpace(target.Host), URL: strings.TrimSpace(target.URL)} - previousLanguage := i18n.GetLanguage() - i18n.SetLanguage(fv.Language) - defer i18n.SetLanguage(previousLanguage) + if strings.TrimSpace(s.config.Language) != "" { + previousLanguage := i18n.GetLanguage() + i18n.SetLanguage(fv.Language) + defer i18n.SetLanguage(previousLanguage) + } cfg, state, err := common.BuildConfig(fv, &info) if err != nil { - return err + return core.ScanReport{}, err } if len(s.config.UserPassPairs) > 0 { 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.ResultSink = sink - core.RunScan(ctx, info, session) - return nil + return core.RunScan(ctx, info, session) } func validateConfig(config Config, targets []Target) error { @@ -392,6 +425,75 @@ func pluginPorts(name string) []int { 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 { for _, plugin := range defaultSafePlugins { if plugin == name { @@ -426,6 +528,34 @@ func convertOutputResult(raw *output.ScanResult) (Result, bool) { 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 { mu.Lock() defer mu.Unlock() diff --git a/pkg/fscan/scanner_test.go b/pkg/fscan/scanner_test.go index 910d69f..89f1e70 100644 --- a/pkg/fscan/scanner_test.go +++ b/pkg/fscan/scanner_test.go @@ -79,6 +79,9 @@ func TestIsSafePlugin(t *testing.T) { if IsSafePlugin("webpoc") { t.Fatal("webpoc should not be safe") } + if IsSafePlugin("ms17010") { + t.Fatal("active poc plugins should not be safe") + } if IsSafePlugin("definitely-missing") { t.Fatal("unknown plugin should not be safe") } @@ -107,6 +110,9 @@ func TestListPlugins(t *testing.T) { if !containsString(ssh.Types, PluginTypeService) { 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) { t.Fatalf("ssh ports = %#v, want 22", ssh.Ports) } @@ -123,6 +129,9 @@ func TestListPlugins(t *testing.T) { if !containsString(webpoc.Types, PluginTypeWeb) { 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) { @@ -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) { configured := startFTPListener(t) 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 { t.Helper() diff --git a/pkg/fscan/types.go b/pkg/fscan/types.go index 1794eba..7e819d2 100644 --- a/pkg/fscan/types.go +++ b/pkg/fscan/types.go @@ -13,6 +13,19 @@ const ( 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 ( // ResultTypeHost reports a live host. ResultTypeHost = "HOST" @@ -40,11 +53,12 @@ type CredentialPair struct { // 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"` + Name string `json:"name"` + Types []string `json:"types,omitempty"` + Capabilities []string `json:"capabilities,omitempty"` + Ports []int `json:"ports,omitempty"` + Safe bool `json:"safe"` + Default bool `json:"default"` } // ResultSummary counts common result categories. @@ -58,6 +72,27 @@ type ResultSummary struct { 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 // scanner. Returning an error asks the scanner to stop and returns that error // to the caller. @@ -114,3 +149,37 @@ type Result struct { Status string `json:"status"` 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"` +} diff --git a/web/api/scan.go b/web/api/scan.go index 6ed4f64..195f51d 100644 --- a/web/api/scan.go +++ b/web/api/scan.go @@ -5,6 +5,7 @@ package api import ( "context" "encoding/json" + "errors" "net/http" "sync" "sync/atomic" @@ -47,10 +48,10 @@ type ScanRequest struct { Domain string `json:"domain"` // POC - PocPath string `json:"poc_path"` - PocName string `json:"poc_name"` - PocFull bool `json:"poc_full"` - DisablePoc bool `json:"disable_poc"` + PocPath string `json:"poc_path"` + PocName string `json:"poc_name"` + PocFull bool `json:"poc_full"` + DisablePoc bool `json:"disable_poc"` // 项目缓存 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 != "" {