mirror of
https://github.com/shadow1ng/fscan.git
synced 2026-09-22 03:10:42 +08:00
polish embedded scanner SDK contracts
This commit is contained in:
+36
-2
@@ -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 完成扫描并输出结果
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-10
@@ -33,10 +33,7 @@ func main() {
|
||||
}
|
||||
|
||||
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{
|
||||
report, err := scanner.ScanReport(context.Background(), fscan.Target{
|
||||
Host: "192.168.1.10",
|
||||
Ports: []int{22, 3306, 6379},
|
||||
})
|
||||
@@ -44,7 +41,13 @@ func main() {
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -60,8 +60,10 @@ func TestResultPortDoesNotParseBareIPv6(t *testing.T) {
|
||||
func TestResultCredentialHelpers(t *testing.T) {
|
||||
result := Result{
|
||||
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"},
|
||||
|
||||
+146
-16
@@ -87,6 +87,7 @@ func GetPlugin(name string) (PluginInfo, bool) {
|
||||
return PluginInfo{
|
||||
Name: name,
|
||||
Types: pluginTypes(name),
|
||||
Capabilities: PluginCapabilities(name),
|
||||
Ports: pluginPorts(name),
|
||||
Safe: IsSafePlugin(name),
|
||||
Default: isDefaultSafePlugin(name),
|
||||
@@ -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)}
|
||||
|
||||
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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -42,6 +55,7 @@ type CredentialPair struct {
|
||||
type PluginInfo struct {
|
||||
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"`
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
+4
-1
@@ -5,6 +5,7 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -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 != "" {
|
||||
|
||||
Reference in New Issue
Block a user